From 06020aa0844f4c1df4c04647f1462cd2b043d4e4 Mon Sep 17 00:00:00 2001 From: rose_cat707 Date: Sun, 25 Jan 2026 20:03:03 +0800 Subject: [PATCH] init --- package-lock.json | 10 + package.json | 2 +- src/components/JsonTreeNode.vue | 186 ++++- src/main.js | 2 + src/views/ColorConverter.vue | 60 +- src/views/EncoderDecoder.vue | 52 +- src/views/Home.vue | 16 + src/views/JsonFormatter.vue | 1090 +++++++++++++++++++++++++++--- src/views/TimestampConverter.vue | 78 ++- 9 files changed, 1309 insertions(+), 187 deletions(-) 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 @@ @@ -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 @@
-
+
{{ n }}
@@ -102,6 +77,7 @@ v-model="inputJson" @paste="handlePaste" @input="updateLineCount" + @focus="adjustTextareaHeight" placeholder='请输入或粘贴JSON数据,例如:{"name":"工具箱","version":1.0}' class="json-editor" > @@ -126,32 +102,89 @@
+
+ + + +
+
+ {{ item }} +
+
+
+
-
-
{{ n }}
-
在左侧输入或粘贴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" /> @@ -98,10 +89,12 @@
当前时间戳:
{{ currentTimestampDisplay }} - + -
@@ -116,19 +109,12 @@
- - - - - - - + + {{ toastMessage }}
@@ -617,7 +603,7 @@ onUnmounted(() => { .current-timestamp-controls { display: flex; align-items: center; - gap: 0.75rem; + gap: 1rem; } .current-timestamp-value { @@ -651,6 +637,33 @@ onUnmounted(() => { transform: scale(0.98); } +.control-btn-icon { + padding: 0.375rem; + background: transparent; + border: 1px solid #d0d0d0; + border-radius: 6px; + color: #666666; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; + width: 2rem; + height: 2rem; + font-size: 0.875rem; +} + +.control-btn-icon:hover { + background: #f5f5f5; + border-color: #1a1a1a; + color: #1a1a1a; +} + +.control-btn-icon:active { + transform: scale(0.98); +} + .input-field { flex: 1; padding: 0.75rem; @@ -789,7 +802,8 @@ onUnmounted(() => { color: #333333; } -.toast-content svg { +.toast-content svg, +.toast-content i { flex-shrink: 0; } @@ -876,16 +890,18 @@ onUnmounted(() => { } .current-timestamp-controls { - flex-direction: column; - align-items: stretch; + flex-direction: row; + align-items: center; + justify-content: flex-start; } .current-timestamp-value { text-align: left; } - .control-btn { - width: 100%; + .control-btn-icon { + width: 2rem; + height: 2rem; } .toast-notification {