Greasy Fork is available in English.
优化查看接口返回的 JSON 数据,支持展开/收起和搜索,双击查看路径
// ==UserScript==
// @name JSON 数据查看器
// @namespace http://tampermonkey.net/
// @version 1.0.1
// @description 优化查看接口返回的 JSON 数据,支持展开/收起和搜索,双击查看路径
// @author zonahaha
// @match http://*/*
// @grant none
// @license MIT
// @run-at document-end
// ==/UserScript==
(function() {
'use strict'
// 创建样式
const style = document.createElement('style')
style.textContent = `
.json-viewer-container {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 90vw;
max-width: 1200px;
height: 80vh;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 999999;
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.3s ease;
}
.json-viewer-container.minimized {
top: auto;
left: auto;
right: 20px;
bottom: 90px;
transform: none;
width: 50px;
height: 50px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border-radius: 50%;
background: #4C5FC0;
padding: 0;
}
.json-viewer-container.minimized:hover {
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
.json-viewer-container.minimized .json-viewer-search,
.json-viewer-container.minimized .json-viewer-content {
display: none;
}
.json-viewer-container.minimized .json-viewer-header {
border-radius: 50%;
padding: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
border-bottom: none;
background: transparent;
}
.json-viewer-container.minimized .json-viewer-title {
display: none;
}
.json-viewer-container.minimized .json-viewer-header-buttons {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
padding: 0;
}
.json-viewer-container.minimized .json-viewer-minimize {
padding: 0;
font-size: 14px;
width: 100%;
height: 100%;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: white;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
}
.json-viewer-container.minimized .json-viewer-minimize:hover {
background: transparent;
}
.json-viewer-container.minimized .json-viewer-format,
.json-viewer-container.minimized .json-viewer-clear,
.json-viewer-container.minimized .json-viewer-close {
display: none;
}
.json-viewer-header {
padding: 16px 20px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f5f5;
border-radius: 8px 8px 0 0;
}
.json-viewer-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin: 0;
}
.json-viewer-header-buttons {
display: flex;
gap: 8px;
align-items: center;
}
.json-viewer-minimize {
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.json-viewer-minimize:hover {
background: #2196A3;
}
.json-viewer-close {
background: #ff4444;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.json-viewer-close:hover {
background: #cc0000;
}
.json-viewer-clear {
background: #9e9e9e;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.json-viewer-clear:hover {
background: #757575;
}
.json-viewer-format {
background: #4caf51;
color: white;
border: none;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s;
}
.json-viewer-format:hover {
background: #7b1fa2;
}
.json-viewer-search {
padding: 12px 20px;
border-bottom: 1px solid #e0e0e0;
background: #fafafa;
}
.json-viewer-search input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.json-viewer-search input:focus {
outline: none;
border-color: #4CAF50;
}
.json-viewer-content {
flex: 1;
overflow: auto;
padding: 20px;
background: #fff;
word-wrap: break-word;
overflow-wrap: break-word;
}
.json-viewer-toggle-btn {
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
margin-bottom: 10px;
margin-left: 5px;
transition: background 0.2s;
}
.json-viewer-toggle-btn:hover {
background: #45a049;
}
.json-viewer-toggle-btn.collapse-all {
background: #2196F3;
}
.json-viewer-toggle-btn.collapse-all:hover {
background: #0b7dda;
}
.json-item {
margin: 4px 0;
font-size: 14px;
line-height: 1.6;
width: 100%;
}
.json-key-container {
display: block;
margin: 2px 0;
}
.json-key-container.inline-value {
display: flex;
flex-wrap: wrap;
align-items: baseline;
line-height: 1.6;
}
.json-key-container.inline-value .json-key {
flex-shrink: 0;
vertical-align: baseline;
}
.json-key-container.inline-value .json-value-wrapper {
flex: 1;
min-width: 0;
vertical-align: baseline;
}
.json-key-container.inline-value .json-value-wrapper > * {
vertical-align: baseline;
}
.json-key {
color: #881391;
font-weight: 500;
display: inline;
white-space: nowrap;
vertical-align: baseline;
}
.json-value-wrapper {
display: inline;
word-break: break-word;
overflow-wrap: break-word;
vertical-align: baseline;
}
.json-value-wrapper.multiline {
display: block;
width: 100%;
margin-left: 0;
}
.json-string {
color: #1a1aa6;
}
.json-number {
color: #1c00cf;
}
.json-boolean {
color: #0e22b0;
font-weight: 600;
}
.json-null {
color: #808080;
font-style: italic;
}
.json-toggle {
cursor: pointer;
user-select: none;
display: inline-block;
width: 16px;
height: 16px;
text-align: center;
line-height: 16px;
margin-right: 4px;
color: #666;
font-weight: bold;
}
.json-toggle:hover {
color: #333;
}
.json-toggle.expanded::before {
content: '▼';
}
.json-toggle.collapsed::before {
content: '▶';
}
.json-children {
margin-left: 20px;
border-left: 1px dashed #ddd;
padding-left: 12px;
width: 100%;
box-sizing: border-box;
}
.json-children.hidden {
display: none;
}
.json-bracket {
color: #666;
font-weight: 500;
}
.highlight {
background: yellow;
padding: 2px 0;
}
.highlight-current {
background: #ffeb3b;
padding: 2px 0;
box-shadow: 0 0 0 2px #ff9800;
border-radius: 2px;
}
.json-viewer-search-controls {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.json-viewer-search-nav {
display: flex;
align-items: center;
gap: 4px;
}
.json-viewer-search-nav-btn {
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.json-viewer-search-nav-btn:hover {
background: #0b7dda;
}
.json-viewer-search-nav-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.json-viewer-search-count {
font-size: 12px;
color: #666;
}
.json-viewer-exact-match {
display: flex;
align-items: center;
gap: 6px;
}
.json-viewer-exact-match label {
font-size: 14px;
color: #666;
cursor: pointer;
user-select: none;
}
.json-viewer-exact-match input[type="checkbox"] {
cursor: pointer;
width: 14px;
height: 14px;
}
.json-viewer-floating-btn {
position: fixed;
bottom: 20px;
right: 20px;
width: 50px;
height: 50px;
background: #4CAF50;
color: white;
border: none;
border-radius: 50%;
font-size: 24px;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
z-index: 999998;
transition: transform 0.2s, background 0.2s;
}
.json-viewer-floating-btn:hover {
transform: scale(1.1);
background: #45a049;
}
.json-viewer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999998;
}
.json-viewer-path-display {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 10px 20px;
font-size: 13px;
font-family: 'Courier New', monospace;
z-index: 1000000;
word-break: break-all;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.3);
border-top: 1px solid rgba(255, 255, 255, 0.1);
min-height: 40px;
display: flex;
align-items: center;
}
.json-viewer-path-display:empty {
display: none;
}
.json-viewer-path-display:not(:empty) {
display: flex;
}
.json-key-container,
.json-value-wrapper,
.json-item {
cursor: pointer;
}
.json-key-container:hover,
.json-value-wrapper:hover {
background: rgba(0, 0, 0, 0.02);
border-radius: 2px;
}
`
document.head.appendChild(style)
// 解析 JSON 字符串(处理序列化的 JSON)
function parseJsonString(str) {
if (typeof str !== 'string') return str
try {
// 尝试解析 JSON 字符串
const parsed = JSON.parse(str)
// 如果解析后是对象或数组,返回解析后的结果
if (typeof parsed === 'object' && parsed !== null) {
return parsed
}
return str
} catch (e) {
return str
}
}
// 深度解析对象中的所有 JSON 字符串
function deepParseJson(obj) {
if (typeof obj === 'string') {
return parseJsonString(obj)
}
if (Array.isArray(obj)) {
return obj.map(item => deepParseJson(item))
}
if (obj !== null && typeof obj === 'object') {
const result = {}
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
result[key] = deepParseJson(obj[key])
}
}
return result
}
return obj
}
// 渲染 JSON 数据
function renderJson(data, searchTerm = '', level = 0, path = '', exactMatch = false) {
// 确保 searchTerm 是字符串
searchTerm = searchTerm || ''
const container = document.createElement('div')
container.className = 'json-item'
container.setAttribute('data-path', path)
if (data === null) {
container.innerHTML = '<span class="json-null">null</span>'
return container
}
if (typeof data === 'string') {
const displayText = searchTerm
? highlightText(data, searchTerm, exactMatch)
: escapeHtml(data)
container.innerHTML = `<span class="json-string">"${displayText}"</span>`
return container
}
if (typeof data === 'number') {
const shouldHighlight = exactMatch ? String(data) === searchTerm : String(data).includes(searchTerm)
const displayNumber = searchTerm && shouldHighlight
? highlightText(String(data), searchTerm, exactMatch)
: String(data)
container.innerHTML = `<span class="json-number">${displayNumber}</span>`
return container
}
if (typeof data === 'boolean') {
const boolStr = String(data)
const shouldHighlight = exactMatch ? boolStr === searchTerm : boolStr.toLowerCase().includes(searchTerm.toLowerCase())
const displayBoolean = searchTerm && shouldHighlight
? highlightText(boolStr, searchTerm, exactMatch)
: boolStr
container.innerHTML = `<span class="json-boolean">${displayBoolean}</span>`
return container
}
if (Array.isArray(data)) {
// 移除搜索过滤,始终显示所有数据
const toggle = document.createElement('span')
toggle.className = 'json-toggle expanded'
toggle.onclick = () => toggleChildren(children, toggle)
const bracket = document.createElement('span')
bracket.className = 'json-bracket'
bracket.textContent = '['
const children = document.createElement('div')
children.className = 'json-children'
if (data.length === 0) {
container.innerHTML = '<span class="json-bracket">[]</span>'
return container
}
// 过滤并渲染匹配的项
let hasMatchingItems = false
data.forEach((item, index) => {
// 始终显示所有项,只高亮匹配的内容
hasMatchingItems = true
const itemContainer = document.createElement('div')
const indexSpan = document.createElement('span')
indexSpan.className = 'json-key'
const shouldHighlightIndex = exactMatch ? String(index) === searchTerm : String(index).includes(searchTerm)
const displayIndex = searchTerm && shouldHighlightIndex
? highlightText(String(index), searchTerm, exactMatch)
: String(index)
indexSpan.innerHTML = `${displayIndex}: `
const valueWrapper = document.createElement('span')
// 计算当前项的路径
const itemPath = path ? `${path}[${index}]` : `[${index}]`
itemContainer.setAttribute('data-path', itemPath)
// 判断值是否是对象或数组
const isComplexValue = (typeof item === 'object' && item !== null) || Array.isArray(item)
if (isComplexValue) {
// 对象或数组:index 和开始括号在同一行,然后换行
itemContainer.className = 'json-key-container'
valueWrapper.className = 'json-value-wrapper multiline'
// 先添加 index 和冒号
itemContainer.appendChild(indexSpan)
// 渲染项,始终显示,传递路径
const itemSearchTerm = searchTerm || ''
const renderedItem = renderJson(item, itemSearchTerm, level + 1, itemPath, exactMatch)
if (renderedItem) {
// 查找 toggle 和开始括号(第一个 json-bracket,应该是 [ 或 {)
const allBrackets = renderedItem.querySelectorAll('.json-bracket')
const itemToggle = renderedItem.querySelector('.json-toggle')
let itemOpenBracket = null
// 找到第一个 { 或 [
for (let i = 0; i < allBrackets.length; i++) {
const bracket = allBrackets[i]
if (bracket.textContent === '{' || bracket.textContent === '[') {
itemOpenBracket = bracket
break
}
}
if (itemToggle && itemOpenBracket) {
// 先找到 children 元素(在 renderedItem 中)
const itemChildren = renderedItem.querySelector('.json-children')
// 将 toggle 和开始括号添加到 index 后面(同一行)
const clonedItemToggle = itemToggle.cloneNode(true)
// 重新绑定事件,确保使用正确的 toggle 和 children
if (itemChildren) {
clonedItemToggle.onclick = () => toggleChildren(itemChildren, clonedItemToggle)
} else {
clonedItemToggle.onclick = () => toggleChildren(valueWrapper, clonedItemToggle)
}
itemContainer.appendChild(clonedItemToggle)
itemContainer.appendChild(itemOpenBracket.cloneNode(true))
// 移除原始的开始括号和 toggle
renderedItem.removeChild(itemToggle)
renderedItem.removeChild(itemOpenBracket)
// 添加剩余内容到 valueWrapper(换行显示)
valueWrapper.appendChild(renderedItem)
itemContainer.appendChild(valueWrapper)
} else {
// 如果没有 toggle,直接添加
valueWrapper.appendChild(renderedItem)
itemContainer.appendChild(valueWrapper)
}
children.appendChild(itemContainer)
}
} else {
// 基本类型:index 和 value 在同一行
itemContainer.className = 'json-key-container inline-value'
valueWrapper.className = 'json-value-wrapper'
// 渲染项,始终显示,传递路径
const itemSearchTerm = searchTerm || ''
const renderedItem = renderJson(item, itemSearchTerm, level + 1, itemPath, exactMatch)
if (renderedItem) {
itemContainer.appendChild(indexSpan)
itemContainer.appendChild(valueWrapper)
valueWrapper.appendChild(renderedItem)
children.appendChild(itemContainer)
}
}
})
const closeBracket = document.createElement('span')
closeBracket.className = 'json-bracket'
closeBracket.textContent = ']'
container.appendChild(toggle)
container.appendChild(bracket)
container.appendChild(children)
container.appendChild(closeBracket)
return container
}
if (typeof data === 'object') {
// 移除搜索过滤,始终显示所有数据
const keys = Object.keys(data)
if (keys.length === 0) {
container.innerHTML = '<span class="json-bracket">{}</span>'
return container
}
const toggle = document.createElement('span')
toggle.className = 'json-toggle expanded'
const children = document.createElement('div')
children.className = 'json-children'
toggle.onclick = () => toggleChildren(children, toggle)
const openBrace = document.createElement('span')
openBrace.className = 'json-bracket'
openBrace.textContent = '{'
keys.forEach(key => {
// 始终显示所有 key,只高亮匹配的内容
const keyContainer = document.createElement('div')
const keySpan = document.createElement('span')
keySpan.className = 'json-key'
const displayKey = searchTerm ? highlightText(key, searchTerm, exactMatch) : escapeHtml(key)
keySpan.innerHTML = `"${displayKey}": `
const valueWrapper = document.createElement('span')
// 计算当前 key 的路径
const keyPath = path ? `${path}.${key}` : key
keyContainer.setAttribute('data-path', keyPath)
// 判断值是否是对象或数组
const value = data[key]
const isComplexValue = (typeof value === 'object' && value !== null) || Array.isArray(value)
if (isComplexValue) {
// 对象或数组:key 和开始括号在同一行,然后换行
keyContainer.className = 'json-key-container'
valueWrapper.className = 'json-value-wrapper multiline'
// 先添加 key 和冒号
keyContainer.appendChild(keySpan)
// 渲染值,始终显示,传递路径
const valueSearchTerm = searchTerm || ''
const renderedValue = renderJson(value, valueSearchTerm, level + 1, keyPath, exactMatch)
if (renderedValue) {
// 查找 toggle 和开始括号(第一个 json-bracket,应该是 {)
const allBrackets = renderedValue.querySelectorAll('.json-bracket')
const valueToggle = renderedValue.querySelector('.json-toggle')
let valueOpenBrace = null
// 找到第一个 { 或 [
for (let i = 0; i < allBrackets.length; i++) {
const bracket = allBrackets[i]
if (bracket.textContent === '{' || bracket.textContent === '[') {
valueOpenBrace = bracket
break
}
}
if (valueToggle && valueOpenBrace) {
// 先找到 children 元素(在 renderedValue 中)
const valueChildren = renderedValue.querySelector('.json-children')
// 将 toggle 和开始括号添加到 key 后面(同一行)
const clonedToggle = valueToggle.cloneNode(true)
// 重新绑定事件,确保使用正确的 toggle 和 children
if (valueChildren) {
clonedToggle.onclick = () => toggleChildren(valueChildren, clonedToggle)
} else {
clonedToggle.onclick = () => toggleChildren(valueWrapper, clonedToggle)
}
keyContainer.appendChild(clonedToggle)
keyContainer.appendChild(valueOpenBrace.cloneNode(true))
// 移除原始的开始括号和 toggle
renderedValue.removeChild(valueToggle)
renderedValue.removeChild(valueOpenBrace)
// 添加剩余内容到 valueWrapper(换行显示)
valueWrapper.appendChild(renderedValue)
keyContainer.appendChild(valueWrapper)
} else {
// 如果没有 toggle,直接添加
valueWrapper.appendChild(renderedValue)
keyContainer.appendChild(valueWrapper)
}
children.appendChild(keyContainer)
} else {
// 如果返回 null,可能是空对象或空数组,强制重新渲染
const forceRendered = renderJson(value, '', level + 1, keyPath, exactMatch)
if (forceRendered) {
valueWrapper.appendChild(forceRendered)
keyContainer.appendChild(valueWrapper)
children.appendChild(keyContainer)
}
}
} else {
// 基本类型:key 和 value 在同一行
keyContainer.className = 'json-key-container inline-value'
valueWrapper.className = 'json-value-wrapper'
// 渲染值,始终显示,传递路径
const valueSearchTerm = searchTerm || ''
const renderedValue = renderJson(value, valueSearchTerm, level + 1, keyPath, exactMatch)
if (renderedValue) {
keyContainer.appendChild(keySpan)
keyContainer.appendChild(valueWrapper)
valueWrapper.appendChild(renderedValue)
children.appendChild(keyContainer)
}
}
})
const closeBrace = document.createElement('span')
closeBrace.className = 'json-bracket'
closeBrace.textContent = '}'
container.appendChild(toggle)
container.appendChild(openBrace)
container.appendChild(children)
container.appendChild(closeBrace)
return container
}
return container
}
// 切换子元素显示/隐藏
function toggleChildren(children, toggle) {
if (children.classList.contains('hidden')) {
children.classList.remove('hidden')
if (toggle) toggle.classList.remove('collapsed')
if (toggle) toggle.classList.add('expanded')
} else {
children.classList.add('hidden')
if (toggle) toggle.classList.remove('expanded')
if (toggle) toggle.classList.add('collapsed')
}
}
// 检查数据是否包含搜索关键字
function matchesSearch(data, searchTerm, exactMatch = false) {
// 如果没有搜索关键字,返回 true(显示所有内容)
if (!searchTerm || typeof searchTerm !== 'string' || searchTerm.trim() === '') {
return true
}
if (exactMatch) {
// 全匹配模式:完全相等
if (data === null) {
return 'null' === searchTerm
}
if (typeof data === 'string') {
return data === searchTerm
}
if (typeof data === 'number') {
return String(data) === searchTerm
}
if (typeof data === 'boolean') {
return String(data) === searchTerm
}
if (Array.isArray(data)) {
return data.some(item => matchesSearch(item, searchTerm, exactMatch))
}
if (typeof data === 'object') {
return Object.keys(data).some(key => {
return key === searchTerm || matchesSearch(data[key], searchTerm, exactMatch)
})
}
} else {
// 包含匹配模式
const lowerSearchTerm = searchTerm.toLowerCase()
if (data === null) {
return 'null'.includes(lowerSearchTerm)
}
if (typeof data === 'string') {
return data.toLowerCase().includes(lowerSearchTerm)
}
if (typeof data === 'number') {
return String(data).includes(searchTerm)
}
if (typeof data === 'boolean') {
return String(data).includes(lowerSearchTerm)
}
if (Array.isArray(data)) {
return data.some(item => matchesSearch(item, searchTerm, exactMatch))
}
if (typeof data === 'object') {
return Object.keys(data).some(key => {
return key.toLowerCase().includes(lowerSearchTerm) || matchesSearch(data[key], searchTerm, exactMatch)
})
}
}
return false
}
// 高亮搜索文本
function highlightText(text, searchTerm, exactMatch = false) {
if (!searchTerm) return escapeHtml(text)
if (exactMatch) {
// 全匹配模式:只有当文本完全等于搜索词时才高亮
if (text === searchTerm) {
return `<span class="highlight">${escapeHtml(text)}</span>`
}
return escapeHtml(text)
} else {
// 包含匹配模式:文本包含搜索词时高亮
const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi')
return escapeHtml(text).replace(regex, '<span class="highlight">$1</span>')
}
}
// 转义 HTML
function escapeHtml(text) {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// 转义正则表达式特殊字符
function escapeRegex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
// 展开/收起所有
function toggleAll(container, expand) {
const toggles = container.querySelectorAll('.json-toggle')
const children = container.querySelectorAll('.json-children')
toggles.forEach((toggle, index) => {
if (expand) {
children[index].classList.remove('hidden')
toggle.classList.remove('collapsed')
toggle.classList.add('expanded')
} else {
children[index].classList.add('hidden')
toggle.classList.remove('expanded')
toggle.classList.add('collapsed')
}
})
}
// 创建弹窗
function createViewer(data) {
// 移除已存在的弹窗
const existing = document.getElementById('json-viewer-container')
if (existing) existing.remove()
// 创建遮罩层
const overlay = document.createElement('div')
overlay.className = 'json-viewer-overlay'
overlay.onclick = () => closeViewer()
// 创建容器
const container = document.createElement('div')
container.id = 'json-viewer-container'
container.className = 'json-viewer-container'
// 收起状态下点击容器可以展开
container.onclick = (e) => {
if (container.classList.contains('minimized') && !e.target.closest('.json-viewer-header-buttons')) {
expandViewer(container)
}
}
// 创建头部
const header = document.createElement('div')
header.className = 'json-viewer-header'
const title = document.createElement('h3')
title.className = 'json-viewer-title'
title.textContent = 'JSON 数据查看器'
// 创建按钮容器
const buttonContainer = document.createElement('div')
buttonContainer.className = 'json-viewer-header-buttons'
// 格式化按钮
const formatBtn = document.createElement('button')
formatBtn.className = 'json-viewer-format'
formatBtn.textContent = '修改数据'
formatBtn.title = '输入新的 JSON 数据并格式化显示'
formatBtn.onclick = (e) => {
e.stopPropagation()
showFormatDialog(container, data)
}
// 收起按钮
const minimizeBtn = document.createElement('button')
minimizeBtn.className = 'json-viewer-minimize'
minimizeBtn.textContent = '收起'
minimizeBtn.onclick = (e) => {
e.stopPropagation()
if (container.classList.contains('minimized')) {
expandViewer(container)
} else {
minimizeViewer(container)
}
}
// 关闭按钮
const closeBtn = document.createElement('button')
closeBtn.className = 'json-viewer-close'
closeBtn.textContent = '关闭'
closeBtn.onclick = (e) => {
e.stopPropagation()
closeViewer()
}
buttonContainer.appendChild(formatBtn)
buttonContainer.appendChild(minimizeBtn)
buttonContainer.appendChild(closeBtn)
header.appendChild(title)
header.appendChild(buttonContainer)
// 创建搜索框
const searchContainer = document.createElement('div')
searchContainer.className = 'json-viewer-search'
const searchInput = document.createElement('input')
searchInput.type = 'text'
searchInput.placeholder = '搜索关键字...'
searchInput.id = 'json-viewer-search-input'
// 搜索导航控件
const searchControls = document.createElement('div')
searchControls.className = 'json-viewer-search-controls'
const searchNav = document.createElement('div')
searchNav.className = 'json-viewer-search-nav'
const prevBtn = document.createElement('button')
prevBtn.className = 'json-viewer-search-nav-btn'
prevBtn.textContent = '↑ 上一个'
prevBtn.id = 'json-viewer-search-prev'
const nextBtn = document.createElement('button')
nextBtn.className = 'json-viewer-search-nav-btn'
nextBtn.textContent = '↓ 下一个'
nextBtn.id = 'json-viewer-search-next'
const searchCount = document.createElement('span')
searchCount.className = 'json-viewer-search-count'
searchCount.id = 'json-viewer-search-count'
searchCount.textContent = ''
// 全匹配复选框
const exactMatchContainer = document.createElement('div')
exactMatchContainer.className = 'json-viewer-exact-match'
const exactMatchCheckbox = document.createElement('input')
exactMatchCheckbox.type = 'checkbox'
exactMatchCheckbox.id = 'json-viewer-exact-match'
const exactMatchLabel = document.createElement('label')
exactMatchLabel.setAttribute('for', 'json-viewer-exact-match')
exactMatchLabel.textContent = '全匹配'
exactMatchContainer.appendChild(exactMatchCheckbox)
exactMatchContainer.appendChild(exactMatchLabel)
searchNav.appendChild(prevBtn)
searchNav.appendChild(nextBtn)
searchNav.appendChild(searchCount)
searchControls.appendChild(searchNav)
searchControls.appendChild(exactMatchContainer)
// 存储匹配项和当前索引
let searchMatches = []
let currentMatchIndex = -1
// 搜索函数
const performSearch = (searchTerm) => {
searchMatches = []
currentMatchIndex = -1
// 读取全匹配复选框的值
const exactMatch = exactMatchCheckbox.checked
if (!searchTerm) {
searchCount.textContent = ''
prevBtn.disabled = true
nextBtn.disabled = true
// 移除所有高亮
const content = container.querySelector('#json-viewer-content')
if (content) {
content.querySelectorAll('.highlight-current').forEach(el => {
el.classList.remove('highlight-current')
el.classList.add('highlight')
})
}
updateContent(container, data, searchTerm, exactMatch)
return
}
updateContent(container, data, searchTerm, exactMatch)
// 收集所有匹配的元素
setTimeout(() => {
const content = container.querySelector('#json-viewer-content')
if (content) {
const highlights = content.querySelectorAll('.highlight')
searchMatches = Array.from(highlights)
if (searchMatches.length > 0) {
searchCount.textContent = `找到 ${searchMatches.length} 个匹配项`
prevBtn.disabled = false
nextBtn.disabled = false
// 滚动到第一个匹配项
scrollToMatch(0)
} else {
searchCount.textContent = '未找到匹配项'
prevBtn.disabled = true
nextBtn.disabled = true
}
}
}, 100)
}
// 滚动到匹配项
const scrollToMatch = (index) => {
if (searchMatches.length === 0 || index < 0 || index >= searchMatches.length) {
return
}
// 移除当前高亮
searchMatches.forEach(el => {
el.classList.remove('highlight-current')
if (!el.classList.contains('highlight')) {
el.classList.add('highlight')
}
})
// 设置新的当前高亮
currentMatchIndex = index
const currentMatch = searchMatches[index]
currentMatch.classList.remove('highlight')
currentMatch.classList.add('highlight-current')
// 滚动到匹配项
const content = container.querySelector('#json-viewer-content')
if (content && currentMatch) {
// 确保父节点展开
let parent = currentMatch.parentElement
while (parent && parent !== content) {
if (parent.classList.contains('json-children')) {
parent.classList.remove('hidden')
const toggle = parent.previousElementSibling
if (toggle && toggle.classList.contains('json-toggle')) {
toggle.classList.remove('collapsed')
toggle.classList.add('expanded')
}
}
parent = parent.parentElement
}
// 滚动到元素
currentMatch.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
// 更新按钮状态
prevBtn.disabled = index === 0
nextBtn.disabled = index === searchMatches.length - 1
}
// 上一个匹配项
prevBtn.onclick = () => {
if (currentMatchIndex > 0) {
scrollToMatch(currentMatchIndex - 1)
}
}
// 下一个匹配项
nextBtn.onclick = () => {
if (currentMatchIndex < searchMatches.length - 1) {
scrollToMatch(currentMatchIndex + 1)
}
}
// 键盘快捷键
searchInput.onkeydown = (e) => {
if (e.key === 'Enter') {
e.preventDefault()
if (e.shiftKey) {
// Shift+Enter: 上一个
if (!prevBtn.disabled) {
prevBtn.click()
}
} else {
// Enter: 下一个
if (!nextBtn.disabled) {
nextBtn.click()
}
}
}
}
let searchTimeout
searchInput.oninput = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
const searchTerm = searchInput.value.trim()
performSearch(searchTerm)
}, 300)
}
// 全匹配复选框变化时重新搜索
exactMatchCheckbox.onchange = () => {
const searchTerm = searchInput.value.trim()
if (searchTerm) {
performSearch(searchTerm)
}
}
prevBtn.disabled = true
nextBtn.disabled = true
searchContainer.appendChild(searchInput)
searchContainer.appendChild(searchControls)
// 创建内容区域
const content = document.createElement('div')
content.className = 'json-viewer-content'
content.id = 'json-viewer-content'
// 创建控制按钮
const controls = document.createElement('div')
const expandAllBtn = document.createElement('button')
expandAllBtn.className = 'json-viewer-toggle-btn'
expandAllBtn.textContent = '展开全部'
expandAllBtn.onclick = () => toggleAll(content, true)
const collapseAllBtn = document.createElement('button')
collapseAllBtn.className = 'json-viewer-toggle-btn collapse-all'
collapseAllBtn.textContent = '收起全部'
collapseAllBtn.onclick = () => toggleAll(content, false)
controls.appendChild(expandAllBtn)
controls.appendChild(collapseAllBtn)
content.appendChild(controls)
updateContent(content, data, '')
// 创建路径显示区域
const pathDisplay = document.createElement('div')
pathDisplay.className = 'json-viewer-path-display'
pathDisplay.id = 'json-viewer-path-display'
document.body.appendChild(pathDisplay)
// 添加双击事件监听
content.addEventListener('dblclick', (e) => {
// 查找最近的包含 data-path 的元素
let target = e.target
let pathElement = null
// 向上查找,找到包含 data-path 的元素
while (target && target !== content) {
if (target.hasAttribute && target.hasAttribute('data-path')) {
pathElement = target
break
}
target = target.parentElement
}
// 如果点击的是 key 或 value,尝试找到父容器
if (!pathElement) {
const keyContainer = e.target.closest('.json-key-container')
if (keyContainer && keyContainer.hasAttribute('data-path')) {
pathElement = keyContainer
}
}
if (pathElement) {
const path = pathElement.getAttribute('data-path')
if (path) {
showPath(path)
}
}
})
container.appendChild(header)
container.appendChild(searchContainer)
container.appendChild(content)
document.body.appendChild(overlay)
document.body.appendChild(container)
}
// 显示路径
function showPath(path) {
const pathDisplay = document.getElementById('json-viewer-path-display')
if (pathDisplay) {
pathDisplay.textContent = path || ''
}
}
// 更新内容
function updateContent(container, data, searchTerm, exactMatch = false) {
const content = container.querySelector('#json-viewer-content') || container
const controls = content.querySelector('.json-viewer-toggle-btn')?.parentElement
// 移除旧的 JSON 内容和空数据提示(保留控制按钮)
const oldJsonContent = content.querySelector('.json-item')
if (oldJsonContent) {
oldJsonContent.remove()
}
// 移除空数据提示
const emptyMsg = content.querySelector('div[style*="暂无数据"]')
if (emptyMsg) {
emptyMsg.remove()
}
// 渲染新的 JSON 内容,从根路径开始
const jsonContainer = renderJson(data, searchTerm, 0, '', exactMatch)
if (jsonContainer) {
if (controls && controls.nextSibling) {
content.insertBefore(jsonContainer, controls.nextSibling)
} else {
content.appendChild(jsonContainer)
}
// 如果有搜索关键字,自动展开所有节点以便查看匹配项
if (searchTerm) {
toggleAll(content, true)
}
} else if (searchTerm) {
// 如果没有匹配结果,显示提示
const noResult = document.createElement('div')
noResult.style.cssText = 'padding: 20px; text-align: center; color: #999;'
noResult.textContent = '未找到匹配的结果'
if (controls && controls.nextSibling) {
content.insertBefore(noResult, controls.nextSibling)
} else {
content.appendChild(noResult)
}
}
}
// 收起弹窗
function minimizeViewer(container) {
container.classList.add('minimized')
const overlay = document.querySelector('.json-viewer-overlay')
if (overlay) overlay.style.display = 'none'
// 更新按钮文本
const minimizeBtn = container.querySelector('.json-viewer-minimize')
if (minimizeBtn) {
minimizeBtn.textContent = '展开'
}
}
// 展开弹窗
function expandViewer(container) {
container.classList.remove('minimized')
const overlay = document.querySelector('.json-viewer-overlay')
if (overlay) overlay.style.display = 'block'
// 更新按钮文本
const minimizeBtn = container.querySelector('.json-viewer-minimize')
if (minimizeBtn) {
minimizeBtn.textContent = '收起'
}
}
// 显示格式化对话框
function showFormatDialog(container, currentData) {
// 创建对话框
const dialog = document.createElement('div')
dialog.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80vw;
max-width: 800px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
z-index: 1000000;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`
const dialogOverlay = document.createElement('div')
dialogOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999999;
`
dialogOverlay.onclick = () => {
dialog.remove()
dialogOverlay.remove()
}
const title = document.createElement('h3')
title.style.cssText = 'margin: 0 0 16px 0; font-size: 18px; color: #333;'
title.textContent = '输入 JSON 数据'
const textarea = document.createElement('textarea')
textarea.style.cssText = `
width: 100%;
height: 300px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
`
textarea.placeholder = '请输入 JSON 数据...\n\n示例:\n{\n "name": "test",\n "value": 123\n}'
// 如果有当前数据,尝试格式化后填入
if (currentData) {
try {
textarea.value = JSON.stringify(currentData, null, 2)
} catch (e) {
textarea.value = ''
}
}
const buttonContainer = document.createElement('div')
buttonContainer.style.cssText = 'display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;'
const cancelBtn = document.createElement('button')
cancelBtn.textContent = '取消'
cancelBtn.style.cssText = `
background: #9e9e9e;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
`
cancelBtn.onclick = () => {
dialog.remove()
dialogOverlay.remove()
}
const formatBtn = document.createElement('button')
formatBtn.textContent = '格式化'
formatBtn.style.cssText = `
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
cursor: pointer;
font-size: 14px;
`
formatBtn.onclick = () => {
const jsonStr = textarea.value.trim()
if (!jsonStr) {
alert('请输入 JSON 数据')
return
}
try {
const parsedData = JSON.parse(jsonStr)
const deepParsedData = deepParseJson(parsedData)
// 更新内容
updateContent(container, deepParsedData, '')
// 清空搜索框
const searchInput = container.querySelector('#json-viewer-search-input')
if (searchInput) {
searchInput.value = ''
}
// 重置搜索状态
const searchCount = container.querySelector('#json-viewer-search-count')
const prevBtn = container.querySelector('#json-viewer-search-prev')
const nextBtn = container.querySelector('#json-viewer-search-next')
if (searchCount) searchCount.textContent = ''
if (prevBtn) prevBtn.disabled = true
if (nextBtn) nextBtn.disabled = true
dialog.remove()
dialogOverlay.remove()
} catch (e) {
alert('JSON 格式错误:' + e.message)
}
}
buttonContainer.appendChild(cancelBtn)
buttonContainer.appendChild(formatBtn)
dialog.appendChild(title)
dialog.appendChild(textarea)
dialog.appendChild(buttonContainer)
document.body.appendChild(dialogOverlay)
document.body.appendChild(dialog)
// 聚焦到输入框
textarea.focus()
// 选中所有文本(如果有)
if (textarea.value) {
textarea.select()
}
}
// 关闭弹窗
function closeViewer() {
const container = document.getElementById('json-viewer-container')
const overlay = document.querySelector('.json-viewer-overlay')
const pathDisplay = document.getElementById('json-viewer-path-display')
if (container) container.remove()
if (overlay) overlay.remove()
if (pathDisplay) pathDisplay.remove()
}
// 创建浮动按钮
function createFloatingButton() {
// 检查按钮是否已存在
const existingBtn = document.querySelector('.json-viewer-floating-btn')
if (existingBtn) {
return
}
// 确保 body 存在
if (!document.body) {
setTimeout(createFloatingButton, 100)
return
}
try {
const btn = document.createElement('button')
btn.className = 'json-viewer-floating-btn'
btn.innerHTML = '{^v^}'
btn.style.cssText += 'font-weight: bold; font-size: 16px;font-color:orange'
btn.title = '打开 JSON 查看器'
btn.id = 'json-viewer-floating-btn'
btn.onclick = () => {
// 尝试从剪贴板获取数据
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(text => {
try {
const data = JSON.parse(text)
const parsedData = deepParseJson(data)
createViewer(parsedData)
} catch (e) {
// 如果剪贴板不是 JSON,提示输入
promptForJson()
}
}).catch(() => {
promptForJson()
})
} else {
// 如果不支持剪贴板 API,直接提示输入
promptForJson()
}
}
document.body.appendChild(btn)
} catch (error) {
console.error('创建 JSON 查看器按钮失败:', error)
// 重试
setTimeout(createFloatingButton, 500)
}
}
// 提示用户输入 JSON
function promptForJson() {
const jsonStr = prompt('请输入 JSON 数据或粘贴 JSON 字符串:')
if (jsonStr) {
try {
const data = JSON.parse(jsonStr)
const parsedData = deepParseJson(data)
createViewer(parsedData)
} catch (e) {
alert('无效的 JSON 格式:' + e.message)
}
}
}
// 页面加载完成后创建浮动按钮
function init() {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(createFloatingButton, 100)
})
} else if (document.body) {
setTimeout(createFloatingButton, 100)
} else {
const checkBody = setInterval(() => {
if (document.body) {
clearInterval(checkBody)
createFloatingButton()
}
}, 100)
// 10 秒后停止检查
setTimeout(() => {
clearInterval(checkBody)
if (!document.querySelector('.json-viewer-floating-btn')) {
createFloatingButton()
}
}, 10000)
}
}
// 立即初始化
init()
})()