diff --git a/package-lock.json b/package-lock.json
index 7a8650f..dcccf3c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "toolbox",
"version": "1.0.0",
"dependencies": {
+ "@fortawesome/fontawesome-free": "^7.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
@@ -453,6 +454,15 @@
"node": ">=12"
}
},
+ "node_modules/@fortawesome/fontawesome-free": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmmirror.com/@fortawesome/fontawesome-free/-/fontawesome-free-7.1.0.tgz",
+ "integrity": "sha512-+WxNld5ZCJHvPQCr/GnzCTVREyStrAJjisUPtUxG5ngDA8TMlPnKp6dddlTpai4+1GNmltAeuk1hJEkBohwZYA==",
+ "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
diff --git a/package.json b/package.json
index 03a60a1..40fe623 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@fortawesome/fontawesome-free": "^7.1.0",
"vue": "^3.4.0",
"vue-router": "^4.2.5"
},
@@ -17,4 +18,3 @@
"vite": "^5.0.0"
}
}
-
diff --git a/src/components/JsonTreeNode.vue b/src/components/JsonTreeNode.vue
index 858ac62..029e5b8 100644
--- a/src/components/JsonTreeNode.vue
+++ b/src/components/JsonTreeNode.vue
@@ -1,30 +1,33 @@
-
+
- {{ isExpanded ? '▼' : '▶' }}
+
+ {{ nodePath }}:
- {{ key }}:
+ {{ key }}:
-
+
"{{ displayValue }}"
{{ displayValue }}
-
+
@@ -50,6 +53,22 @@ const props = defineProps({
expanded: {
type: Set,
required: true
+ },
+ matchedPaths: {
+ type: Set,
+ default: () => new Set()
+ },
+ jsonPathQuery: {
+ type: String,
+ default: ''
+ },
+ showPath: {
+ type: Boolean,
+ default: false
+ },
+ nodePath: {
+ type: String,
+ default: ''
}
})
@@ -86,6 +105,73 @@ const isExpanded = computed(() => {
return props.expanded.has(currentPath.value)
})
+// 判断当前节点是否匹配 JSONPath
+const isMatched = computed(() => {
+ if (!props.jsonPathQuery || !props.jsonPathQuery.trim()) return false
+ return props.matchedPaths.has(currentPath.value)
+})
+
+// 判断是否有筛选
+const hasFilter = computed(() => {
+ return props.jsonPathQuery && props.jsonPathQuery.trim() !== ''
+})
+
+// 判断是否应该显示子节点
+const shouldShowChildren = computed(() => {
+ if (!hasFilter.value) return true
+ // 如果节点匹配,显示子节点(展开匹配节点的内容)
+ if (isMatched.value) return true
+ // 如果当前节点是匹配节点的子节点,显示所有子节点
+ if (isChildOfMatched.value) return true
+ // 检查是否有子节点匹配
+ return hasMatchingChildren.value
+})
+
+// 检查是否有子节点匹配
+const hasMatchingChildren = computed(() => {
+ if (!hasFilter.value) return true
+ if (!hasChildren.value) return false
+
+ const basePath = props.path === '' && props.keyName === null ? 'root' : currentPath.value
+
+ if (Array.isArray(props.data)) {
+ return props.data.some((item, index) => {
+ const childPath = `${basePath}[${index}]`
+ return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
+ })
+ }
+
+ if (typeof props.data === 'object' && props.data !== null) {
+ return Object.keys(props.data).some(key => {
+ const childPath = basePath === 'root' ? `root.${key}` : `${basePath}.${key}`
+ return props.matchedPaths.has(childPath) || checkChildrenMatch(props.data[key], childPath)
+ })
+ }
+
+ return false
+})
+
+// 递归检查子节点是否匹配
+const checkChildrenMatch = (obj, path) => {
+ if (props.matchedPaths.has(path)) return true
+
+ if (Array.isArray(obj)) {
+ return obj.some((item, index) => {
+ const childPath = `${path}[${index}]`
+ return props.matchedPaths.has(childPath) || checkChildrenMatch(item, childPath)
+ })
+ }
+
+ if (typeof obj === 'object' && obj !== null) {
+ return Object.keys(obj).some(key => {
+ const childPath = `${path}.${key}`
+ return props.matchedPaths.has(childPath) || checkChildrenMatch(obj[key], childPath)
+ })
+ }
+
+ return false
+}
+
const valueType = computed(() => {
if (props.data === null) return 'null'
if (Array.isArray(props.data)) return 'array'
@@ -132,6 +218,57 @@ const children = computed(() => {
return []
})
+// 筛选后的子节点
+const filteredChildren = computed(() => {
+ if (!hasFilter.value) return children.value
+
+ // 如果当前节点匹配,显示所有子节点(不进行过滤)
+ if (isMatched.value) return children.value
+
+ // 如果当前节点是匹配节点的子节点,显示所有子节点(不进行过滤)
+ if (isChildOfMatched.value) return children.value
+
+ // 否则,只显示匹配的子节点或其子节点匹配的子节点
+ return children.value.filter(child => {
+ const childPath = child.path === 'root'
+ ? (typeof child.key === 'number' ? `root[${child.key}]` : `root.${child.key}`)
+ : (typeof child.key === 'number' ? `${child.path}[${child.key}]` : `${child.path}.${child.key}`)
+
+ // 如果子节点匹配,显示
+ if (props.matchedPaths.has(childPath)) return true
+
+ // 如果子节点的子节点匹配,也显示
+ return checkChildrenMatch(child.value, childPath)
+ })
+})
+
+// 检查当前节点是否是匹配节点的子节点
+const isChildOfMatched = computed(() => {
+ if (!hasFilter.value || isMatched.value) return false
+
+ // 检查当前路径是否以任何匹配路径开头(作为前缀)
+ for (const matchedPath of props.matchedPaths) {
+ if (matchedPath === 'root') continue
+
+ // 如果当前路径以匹配路径开头,且后面跟着 . 或 [,说明是子节点
+ const prefix = matchedPath + '.'
+ const prefixBracket = matchedPath + '['
+ if (currentPath.value.startsWith(prefix) || currentPath.value.startsWith(prefixBracket)) {
+ return true
+ }
+ }
+
+ return false
+})
+
+// 判断是否被筛选(有筛选但当前节点不匹配且没有匹配的子节点,且不是匹配节点的子节点)
+const isFiltered = computed(() => {
+ if (!hasFilter.value) return false
+ if (isMatched.value) return false
+ if (isChildOfMatched.value) return false
+ return !hasMatchingChildren.value
+})
+
const toggle = () => {
if (hasChildren.value) {
emit('toggle', currentPath.value)
@@ -171,12 +308,24 @@ const toggle = () => {
margin-right: 4px;
}
+.expand-icon i {
+ font-size: 10px;
+}
+
.expand-placeholder {
display: inline-block;
width: 16px;
margin-right: 4px;
}
+.node-path {
+ color: #666666;
+ font-size: 0.8125rem;
+ margin-right: 8px;
+ font-family: 'Courier New', monospace;
+ opacity: 0.7;
+}
+
.node-key {
margin-right: 6px;
}
@@ -221,4 +370,27 @@ const toggle = () => {
border-left: 1px solid #e0e0e0;
padding-left: 8px;
}
+
+/* 匹配的节点高亮样式 */
+.json-tree-node.is-matched .node-line {
+ background: #fff9e6;
+ border-left: 3px solid #ff9800;
+ padding-left: 5px;
+ margin-left: -8px;
+}
+
+.json-tree-node.is-matched .key-name.matched {
+ color: #ff6f00;
+ font-weight: 600;
+}
+
+.json-tree-node.is-matched .node-value.matched {
+ color: #ff6f00;
+ font-weight: 500;
+}
+
+/* 被筛选掉的节点隐藏 */
+.json-tree-node.is-filtered {
+ display: none;
+}
diff --git a/src/main.js b/src/main.js
index 9028cbb..510bb86 100644
--- a/src/main.js
+++ b/src/main.js
@@ -2,6 +2,8 @@ import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './style.css'
+// 引入 Font Awesome 6.4
+import '@fortawesome/fontawesome-free/css/all.css'
createApp(App).use(router).mount('#app')
diff --git a/src/views/ColorConverter.vue b/src/views/ColorConverter.vue
index c8774ce..999037c 100644
--- a/src/views/ColorConverter.vue
+++ b/src/views/ColorConverter.vue
@@ -4,20 +4,12 @@
-
-
+
+
{{ toastMessage }}
@@ -60,8 +52,12 @@
@@ -148,10 +128,7 @@
@@ -641,7 +618,8 @@ onUnmounted(() => {
min-width: 0;
}
-.toast-content svg {
+.toast-content svg,
+.toast-content i {
flex-shrink: 0;
}
@@ -685,8 +663,10 @@ onUnmounted(() => {
opacity: 0.8;
}
-.toast-close-btn svg {
+.toast-close-btn svg,
+.toast-close-btn i {
display: block;
+ font-size: 14px;
}
/* 浮层提示动画 */
@@ -991,8 +971,10 @@ onUnmounted(() => {
background: #e5e5e5;
}
-.toolbar-icon-btn svg {
+.toolbar-icon-btn svg,
+.toolbar-icon-btn i {
display: block;
+ font-size: 14px;
}
.editor-container {
diff --git a/src/views/Home.vue b/src/views/Home.vue
index 4732801..8d2018e 100644
--- a/src/views/Home.vue
+++ b/src/views/Home.vue
@@ -15,6 +15,9 @@
{{ tool.description }}
+
@@ -155,5 +158,18 @@ const tools = ref([
padding: 1.5rem;
}
}
+
+.footer-section {
+ text-align: center;
+ padding: 2rem 0;
+ margin-top: 3rem;
+ border-top: 1px solid #e5e5e5;
+}
+
+.icp-info {
+ color: #999999;
+ font-size: 0.875rem;
+ margin: 0;
+}
diff --git a/src/views/JsonFormatter.vue b/src/views/JsonFormatter.vue
index 38e0b31..246e286 100644
--- a/src/views/JsonFormatter.vue
+++ b/src/views/JsonFormatter.vue
@@ -4,20 +4,12 @@
-
-
+
+
{{ toastMessage }}
@@ -54,46 +46,29 @@
-
在左侧输入或粘贴JSON数据,右侧将实时显示树形结构
+
+
+
+
+
+ 未找到匹配的节点
+
+
@@ -175,6 +208,15 @@ const expandedNodes = ref(new Set())
const lineCount = ref(1)
const treeLineCount = ref(1)
const jsonEditorRef = ref(null)
+const editorContainerRef = ref(null)
+
+// 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('')
@@ -221,6 +263,442 @@ const parsedData = computed(() => {
}
})
+// 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()
+ console.error('JSONPath 解析错误:', e)
+ }
+}
+
+// 清除 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) {
+ console.error('加载 JSONPath 历史记录失败', e)
+ 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) {
+ console.error('保存 JSONPath 历史记录失败', e)
+ }
+}
+
+// 选择 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)
+ }
+}
+
// 更新行号
const updateLineCount = () => {
if (inputJson.value) {
@@ -228,15 +706,161 @@ const updateLineCount = () => {
} else {
lineCount.value = 1
}
+ // 更新textarea高度以适应内容
+ adjustTextareaHeight()
+}
+
+// 同步行号容器的滚动位置
+let rafId = null
+const syncLineNumbersScroll = () => {
+ if (rafId) {
+ cancelAnimationFrame(rafId)
+ }
+ rafId = requestAnimationFrame(() => {
+ if (editorContainerRef.value) {
+ const lineNumbers = editorContainerRef.value.querySelector('.line-numbers')
+ if (lineNumbers && editorContainerRef.value) {
+ // 同步滚动位置:当容器向下滚动时,行号容器也需要向下移动相同的距离
+ const scrollTop = editorContainerRef.value.scrollTop
+
+ // 方法1: 使用 transform(优先)
+ const transformValue = `translate3d(0, ${scrollTop}px, 0)`
+ // 直接设置,不使用 removeProperty,避免闪烁
+ lineNumbers.style.transform = transformValue
+ lineNumbers.style.webkitTransform = transformValue
+
+ // 方法2: 同时使用 top 作为备用(如果 transform 不工作)
+ // 当容器滚动时,行号需要跟随内容移动
+ // 由于行号是绝对定位 top: 0,当内容向上滚动 scrollTop 时,行号也需要向上移动 scrollTop
+ // 所以设置 top 为负值
+ lineNumbers.style.top = `${-scrollTop}px`
+ }
+ }
+ rafId = null
+ })
+}
+
+// 创建一个持续同步的函数,用于调试
+let syncInterval = null
+const startContinuousSync = () => {
+ if (syncInterval) {
+ clearInterval(syncInterval)
+ }
+ // 每50ms检查一次滚动位置并同步(作为备用方案)
+ // 使用更短的间隔确保及时同步
+ syncInterval = setInterval(() => {
+ if (editorContainerRef.value) {
+ syncLineNumbersScroll()
+ }
+ }, 50)
+}
+
+const stopContinuousSync = () => {
+ if (syncInterval) {
+ clearInterval(syncInterval)
+ syncInterval = null
+ }
+}
+
+// 调整textarea高度以适应内容
+const adjustTextareaHeight = () => {
+ if (jsonEditorRef.value && editorContainerRef.value) {
+ // 重置高度为auto以获取正确的scrollHeight
+ jsonEditorRef.value.style.height = 'auto'
+ // 设置高度为内容的实际高度(scrollHeight已经包含了padding)
+ const scrollHeight = jsonEditorRef.value.scrollHeight
+ // 确保至少有一行的高度(包括padding)
+ const paddingTop = 16 // 1rem = 16px
+ const paddingBottom = 16 // 1rem = 16px
+ const lineHeight = 22.4 // 14px * 1.6
+ const minHeight = paddingTop + lineHeight + paddingBottom
+ const newHeight = Math.max(scrollHeight, minHeight)
+ jsonEditorRef.value.style.height = newHeight + 'px'
+
+ // 同步调整行号容器的高度,根据实际行数计算
+ const lineNumbers = editorContainerRef.value.querySelector('.line-numbers')
+ if (lineNumbers) {
+ // 先保存当前的 transform 值,避免被重置
+ const currentTransform = lineNumbers.style.transform || lineNumbers.style.webkitTransform || ''
+
+ // 根据实际行数计算行号容器的高度
+ // padding-top + (行数 * 行高) + padding-bottom
+ const calculatedHeight = paddingTop + (lineCount.value * lineHeight) + paddingBottom
+ // 使用计算出的高度和scrollHeight中的较大值,确保完全覆盖
+ const finalHeight = Math.max(calculatedHeight, newHeight)
+
+ // 只设置高度相关的样式,不影响 transform
+ lineNumbers.style.setProperty('height', `${finalHeight}px`, 'important')
+ lineNumbers.style.setProperty('max-height', 'none', 'important')
+ lineNumbers.style.setProperty('min-height', `${finalHeight}px`, 'important')
+ lineNumbers.style.setProperty('overflow', 'visible', 'important')
+
+ // 恢复 transform 值(如果有的话)
+ if (currentTransform) {
+ lineNumbers.style.setProperty('transform', currentTransform, 'important')
+ lineNumbers.style.setProperty('-webkit-transform', currentTransform, 'important')
+ }
+ }
+
+ // 延迟同步滚动位置,确保高度设置完成后再同步
+ setTimeout(() => {
+ syncLineNumbersScroll()
+ }, 0)
+ }
+}
+
+// 重置编辑器滚动位置到顶部
+const resetEditorScroll = () => {
+ // 重置容器的滚动位置(因为滚动是在editor-container上)
+ if (editorContainerRef.value) {
+ editorContainerRef.value.scrollTop = 0
+ }
+ // 同时也重置textarea的滚动位置(以防万一)
+ if (jsonEditorRef.value) {
+ jsonEditorRef.value.scrollTop = 0
+ }
+}
+
+// 获取所有路径
+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()
+ // 使用nextTick确保DOM更新后再调整高度
+ setTimeout(() => {
+ adjustTextareaHeight()
+ }, 0)
if (inputJson.value.trim()) {
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)
@@ -259,6 +883,7 @@ const formatJson = () => {
const parsed = JSON.parse(inputJson.value)
inputJson.value = JSON.stringify(parsed, null, 2)
updateLineCount()
+ resetEditorScroll()
showToast('格式化成功', 'info', 2000)
} catch (e) {
showToast('JSON格式错误:' + e.message)
@@ -275,6 +900,7 @@ const minifyJson = () => {
const parsed = JSON.parse(inputJson.value)
inputJson.value = JSON.stringify(parsed)
updateLineCount()
+ resetEditorScroll()
showToast('压缩成功', 'info', 2000)
} catch (e) {
showToast('JSON格式错误:' + e.message)
@@ -288,8 +914,64 @@ const escapeJson = () => {
return
}
try {
- inputJson.value = JSON.stringify(inputJson.value)
+ 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,保持原样
+ }
+ // 添加引号并转义
+ inputJson.value = JSON.stringify(jsonToEscape)
+ }
+ }
+
updateLineCount()
+ resetEditorScroll()
showToast('转义成功', 'info', 2000)
} catch (e) {
showToast('转义失败:' + e.message)
@@ -303,11 +985,78 @@ const unescapeJson = () => {
return
}
try {
- inputJson.value = JSON.parse(inputJson.value)
- updateLineCount()
- showToast('取消转义成功', 'info', 2000)
+ 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)
+ // 如果解析成功,自动格式化
+ inputJson.value = JSON.stringify(parsed, null, 2)
+ updateLineCount()
+ resetEditorScroll()
+ showToast('取消转义并格式化成功', 'info', 2000)
+ return
+ } catch (e) {
+ // 如果解析失败,说明只是普通字符串,保持原样
+ inputJson.value = unescaped
+ updateLineCount()
+ resetEditorScroll()
+ showToast('取消转义成功', 'info', 2000)
+ return
+ }
+ }
+
+ // 如果取消转义后是对象或数组,自动格式化
+ if (typeof unescaped === 'object' && unescaped !== null) {
+ inputJson.value = JSON.stringify(unescaped, null, 2)
+ updateLineCount()
+ resetEditorScroll()
+ showToast('取消转义并格式化成功', 'info', 2000)
+ } else {
+ // 其他类型(数字、布尔值等),转换为字符串
+ inputJson.value = String(unescaped)
+ updateLineCount()
+ resetEditorScroll()
+ showToast('取消转义成功', 'info', 2000)
+ }
} catch (e) {
- showToast('取消转义失败:请检查输入是否为有效的转义JSON字符串')
+ showToast('取消转义失败:' + e.message)
}
}
@@ -334,6 +1083,7 @@ const pasteFromClipboard = async () => {
if (text.trim()) {
inputJson.value = text
updateLineCount()
+ // 粘贴后不重置滚动位置,保持在当前位置
// 检查是否是有效的JSON,如果是则保存到历史记录
try {
JSON.parse(text)
@@ -351,7 +1101,6 @@ const pasteFromClipboard = async () => {
return
} catch (e) {
// 如果 Clipboard API 失败,使用备用方法
- console.log('Clipboard API 失败,使用备用方法:', e)
}
}
@@ -383,6 +1132,8 @@ const handlePaste = async (event) => {
// 等待下一个tick,确保inputJson已更新
await new Promise(resolve => setTimeout(resolve, 0))
+ // 粘贴后不重置滚动位置,保持在当前位置
+
// 检查是否是有效的JSON
try {
JSON.parse(inputJson.value)
@@ -450,6 +1201,7 @@ const loadHistory = (json) => {
inputJson.value = json
expandedNodes.value.clear()
updateLineCount()
+ resetEditorScroll()
}
// 切换侧栏
@@ -502,32 +1254,6 @@ const collapseAll = () => {
expandedNodes.value.clear()
}
-// 获取所有路径
-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
-}
-
// 拖拽调整宽度
const startResize = (e) => {
isResizing.value = true
@@ -562,11 +1288,45 @@ const stopResize = () => {
onMounted(() => {
loadHistoryList()
+ loadJsonPathHistory()
+ // 初始化textarea高度
+ adjustTextareaHeight()
+ // 监听容器滚动事件,同步行号位置
+ if (editorContainerRef.value) {
+ // 监听滚动事件 - 使用 capture 模式确保能捕获到事件
+ editorContainerRef.value.addEventListener('scroll', syncLineNumbersScroll, {
+ passive: true,
+ capture: false
+ })
+
+ // 也监听wheel事件,确保鼠标滚轮滚动时也能同步
+ editorContainerRef.value.addEventListener('wheel', () => {
+ setTimeout(syncLineNumbersScroll, 0)
+ }, { passive: true })
+
+ // 初始同步
+ setTimeout(() => {
+ syncLineNumbersScroll()
+ // 启动持续同步(作为备用方案)
+ startContinuousSync()
+ }, 100)
+ }
})
onUnmounted(() => {
document.removeEventListener('mousemove', handleResize)
document.removeEventListener('mouseup', stopResize)
+ // 清理滚动事件监听器
+ if (editorContainerRef.value) {
+ editorContainerRef.value.removeEventListener('scroll', syncLineNumbersScroll)
+ }
+ // 清理动画帧
+ if (rafId) {
+ cancelAnimationFrame(rafId)
+ rafId = null
+ }
+ // 停止持续同步
+ stopContinuousSync()
})
@@ -608,7 +1368,8 @@ onUnmounted(() => {
min-width: 0;
}
-.toast-content svg {
+.toast-content svg,
+.toast-content i {
flex-shrink: 0;
}
@@ -652,8 +1413,10 @@ onUnmounted(() => {
opacity: 0.8;
}
-.toast-close-btn svg {
+.toast-close-btn svg,
+.toast-close-btn i {
display: block;
+ font-size: 14px;
}
/* 浮层提示动画 */
@@ -813,15 +1576,19 @@ onUnmounted(() => {
display: flex;
position: relative;
background: #ffffff;
+ overflow: hidden;
+ min-height: 0;
}
.left-panel,
.right-panel {
display: flex;
flex-direction: column;
- height: 100%;
+ flex: 1;
overflow: hidden;
background: #ffffff;
+ min-height: 0;
+ max-height: 100%;
}
.left-panel {
@@ -901,25 +1668,134 @@ onUnmounted(() => {
background: #e5e5e5;
}
-.toolbar-icon-btn svg {
+.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;
}
.editor-container {
flex: 1;
display: flex;
position: relative;
- overflow: hidden;
+ overflow-y: auto;
+ overflow-x: hidden;
background: #ffffff;
+ min-height: 0;
+ max-height: 100%;
}
.line-numbers {
position: absolute;
left: 0;
top: 0;
- bottom: 0;
width: 40px;
- padding: 1rem 0.5rem;
+ padding-top: 1rem;
+ padding-bottom: 1rem;
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
background: #fafafa;
border-right: 1px solid #e5e5e5;
font-family: 'Courier New', monospace;
@@ -928,11 +1804,21 @@ onUnmounted(() => {
text-align: right;
user-select: none;
z-index: 1;
+ pointer-events: none;
+ /* 确保行号容器可以超出容器高度 */
+ overflow: visible;
+ max-height: none !important;
+ /* 移除初始 transform,让 JavaScript 完全控制 */
+ transform: none;
+ will-change: top, transform;
}
.line-number {
line-height: 1.6;
height: 22.4px;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
}
.json-editor {
@@ -947,6 +1833,9 @@ onUnmounted(() => {
background: #ffffff;
color: #1a1a1a;
line-height: 1.6;
+ overflow: hidden;
+ min-height: 0;
+ box-sizing: border-box;
}
.json-editor:focus {
@@ -1010,22 +1899,23 @@ onUnmounted(() => {
}
/* 右侧面板 */
-.right-panel {
- background: #ffffff;
-}
-
.tree-container {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
background: #ffffff;
+ min-height: 0;
}
.tree-content {
- flex: 1;
+ flex: 1 1 0;
overflow-y: auto;
- padding: 1rem 1rem 1rem 3rem;
+ overflow-x: hidden;
+ padding: 1rem;
+ min-height: 0;
+ box-sizing: border-box;
+ position: relative;
}
.empty-state {
@@ -1035,6 +1925,13 @@ onUnmounted(() => {
font-size: 0.875rem;
}
+/* 匹配节点列表样式 */
+.matched-nodes-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
@media (max-width: 768px) {
.tool-page {
padding: 0;
@@ -1071,7 +1968,18 @@ onUnmounted(() => {
}
.tree-content {
- padding-left: 2.5rem;
+ 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 {
diff --git a/src/views/TimestampConverter.vue b/src/views/TimestampConverter.vue
index b75b2ed..46dd82b 100644
--- a/src/views/TimestampConverter.vue
+++ b/src/views/TimestampConverter.vue
@@ -36,10 +36,7 @@
class="input-field"
/>
@@ -58,10 +55,7 @@
class="input-field readonly"
/>
@@ -85,10 +79,7 @@
class="input-field readonly"
/>