- src/libs/json-tool.js:零依赖 JSON 处理库
- 格式化/压缩,缩进支持 2/4/1 空格与 Tab
- 错误定位优先解析 V8 新版报错的 (line X column Y),
旧版只给 position 时回退按换行换算
- 大整数保护:超出 Number.MAX_SAFE_INTEGER 的整数按原样保留,
避免 JSON.stringify 转科学计数法丢精度(扫描时排除字符串内的数字)
- 可选键名递归排序(数组保持原序)
- 结构统计:键数、深度、数组个数
- src/view/tools/json-format.vue:单输入区页面
- 原地变换:格式化/压缩直接替换文本框内容,可继续编辑再变换
- 原文快照供「还原」,用户手动编辑后快照自动失效
- 错误时保留原文不覆盖,状态条红色显示行列并选中出错附近文本
- 超大文本(>2MB)先确认再处理
- 支持拖入 .json 文件、复制(https 降级 execCommand)
- 快捷键 Ctrl+Enter 格式化、Ctrl+S 压缩
- 沿用文本比对页的视觉语言(靛蓝主色、浅色卡片状态条)
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<div class="json-format-page">
|
||||
<!-- 操作栏 start -->
|
||||
<div class="jf-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<Tooltip content="美化 JSON(Ctrl + Enter)" transfer>
|
||||
<Button type="primary" icon="md-brush" :loading="processing" @click="doFormat()">格式化</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="压缩为单行" transfer>
|
||||
<Button icon="md-contract" :disabled="processing" @click="doMinify()">压缩</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="恢复到上次变换前的内容" transfer>
|
||||
<Button icon="md-undo" :disabled="!canRestore" @click="doRestore()">还原</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<span class="field-label">缩进</span>
|
||||
<Select v-model="indent" class="indent-select" size="small">
|
||||
<Option :value="2">2 空格</Option>
|
||||
<Option :value="4">4 空格</Option>
|
||||
<Option :value="1">1 空格</Option>
|
||||
<Option :value="'tab'">Tab</Option>
|
||||
</Select>
|
||||
<Checkbox v-model="sortKeys">键名排序</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-gap"></div>
|
||||
|
||||
<div class="toolbar-group">
|
||||
<Tooltip content="填充示例数据" transfer>
|
||||
<Button icon="md-flask" :disabled="processing" @click="fillSample()">示例</Button>
|
||||
</Tooltip>
|
||||
<Button icon="md-trash" :disabled="processing" @click="clearAll()">清空</Button>
|
||||
<Tooltip content="复制当前内容到剪贴板" transfer>
|
||||
<Button icon="md-copy" :disabled="!text" @click="copyText()">复制</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 操作栏 end -->
|
||||
|
||||
<!-- 状态条 start -->
|
||||
<div class="jf-status" :class="errorInfo ? 'has-error' : 'is-ok'">
|
||||
<template v-if="errorInfo">
|
||||
<Icon type="ios-alert" size="18" class="status-icon"></Icon>
|
||||
<div class="error-body">
|
||||
<div class="error-loc">
|
||||
第 <b>{{ errorInfo.line }}</b> 行 第 <b>{{ errorInfo.column }}</b> 列
|
||||
<span class="error-reason">{{ errorInfo.reason }}</span>
|
||||
</div>
|
||||
<div class="error-preview" v-if="errorLineText">{{ errorLineText }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="stat">
|
||||
<Icon type="ios-checkmark-circle" size="18" class="status-icon"></Icon>
|
||||
<div class="ok-body">
|
||||
<span class="ok-text">格式正确</span>
|
||||
<span class="stat-divider"></span>
|
||||
<span class="stat-item"><b>{{ stat.keys }}</b> 键</span>
|
||||
<span class="stat-item">深度 <b>{{ stat.maxDepth }}</b></span>
|
||||
<span class="stat-item"><b>{{ lineCount }}</b> 行</span>
|
||||
<span class="stat-item"><b>{{ textSize }}</b></span>
|
||||
<span class="stat-item warn-item" v-if="bigIntCount > 0">
|
||||
<Icon type="ios-warning" size="13"></Icon>含 {{ bigIntCount }} 个超长整数,已按原样保留
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Icon type="ios-information-circle" size="18" class="status-icon idle"></Icon>
|
||||
<div class="ok-body">
|
||||
<span class="ok-text idle-text">粘贴 JSON 内容,或把 .json 文件拖到下方</span>
|
||||
<span class="stat-item" v-if="rawSize"><b>{{ rawSize }}</b></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- 状态条 end -->
|
||||
|
||||
<!-- 输入区 start -->
|
||||
<div class="jf-input-wrap">
|
||||
<Input type="textarea"
|
||||
ref="editor"
|
||||
v-model="text"
|
||||
class="jf-textarea"
|
||||
:placeholder="placeholder"
|
||||
:spellcheck="false"
|
||||
@on-drop.prevent="onDrop"
|
||||
@dragover.prevent
|
||||
@on-change="onTextChanged"/>
|
||||
</div>
|
||||
<!-- 输入区 end -->
|
||||
|
||||
<!-- 快捷键提示 -->
|
||||
<div class="jf-hint">
|
||||
<span>Ctrl + Enter 格式化</span>
|
||||
<span>Ctrl + S 压缩</span>
|
||||
<span>{{ canRestore ? '已保存原始内容,可点「还原」返回' : '变换前会自动保存原始内容' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
formatJson,
|
||||
minifyJson,
|
||||
byteSize,
|
||||
formatSize,
|
||||
LARGE_TEXT_BYTES
|
||||
} from '@/libs/json-tool'
|
||||
|
||||
const SAMPLE = '{"user":{"id":1001,"name":"张家训","roles":["admin","dev"],"active":true,"profile":{"age":30,"city":"Hangzhou","tags":["java","vue","sql"]}},"settings":{"theme":"dark","lang":"zh-CN","notifications":{"email":true,"sms":false}},"createdAt":"2026-10-20T10:30:00Z","loginCount":128,"balance":9999.99}'
|
||||
|
||||
export default {
|
||||
name: 'tools-json-format',
|
||||
data () {
|
||||
return {
|
||||
text: '',
|
||||
indent: 2,
|
||||
sortKeys: false,
|
||||
processing: false,
|
||||
errorInfo: null,
|
||||
stat: null,
|
||||
bigIntCount: 0,
|
||||
// 变换前的内容快照,供「还原」使用;用户手动编辑后快照失效
|
||||
snapshot: '',
|
||||
snapshotValid: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
lineCount () {
|
||||
if (!this.text) return 0
|
||||
return this.text.split('\n').length
|
||||
},
|
||||
textSize () {
|
||||
return formatSize(byteSize(this.text))
|
||||
},
|
||||
rawSize () {
|
||||
return this.text ? formatSize(byteSize(this.text)) : ''
|
||||
},
|
||||
canRestore () {
|
||||
return this.snapshotValid && this.text !== this.snapshot
|
||||
},
|
||||
errorLineText () {
|
||||
if (!this.errorInfo || !this.text) return ''
|
||||
const lines = this.text.split('\n')
|
||||
const line = lines[this.errorInfo.line - 1]
|
||||
if (line === undefined) return ''
|
||||
// 截取出错位置前后片段,长文本也能看清上下文
|
||||
const col = this.errorInfo.column
|
||||
const from = Math.max(0, col - 30)
|
||||
const to = Math.min(line.length, col + 30)
|
||||
const prefix = from > 0 ? '…' : ''
|
||||
const suffix = to < line.length ? '…' : ''
|
||||
return prefix + line.slice(from, to) + suffix
|
||||
},
|
||||
placeholder () {
|
||||
return '在此粘贴 JSON 内容…\n\n例如:{"user":{"id":1,"name":"test"},"list":[1,2,3]}'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
doFormat () {
|
||||
this.applyTransform(false)
|
||||
},
|
||||
doMinify () {
|
||||
this.applyTransform(true)
|
||||
},
|
||||
// 执行格式化/压缩;minify 为 true 时压缩
|
||||
applyTransform (minify) {
|
||||
if (this.processing) return
|
||||
const source = this.text
|
||||
if (!source || !source.trim()) {
|
||||
this.$Message.warning('请先粘贴 JSON 内容')
|
||||
return
|
||||
}
|
||||
const size = byteSize(source)
|
||||
if (size > LARGE_TEXT_BYTES) {
|
||||
this.$Modal.warning({
|
||||
title: '内容较大',
|
||||
content: `当前约 ${formatSize(size)},处理可能短暂卡顿,确认继续吗?`,
|
||||
okText: '继续',
|
||||
cancelText: '取消',
|
||||
onOk: () => this.runTransform(source, minify)
|
||||
})
|
||||
return
|
||||
}
|
||||
this.runTransform(source, minify)
|
||||
},
|
||||
runTransform (source, minify) {
|
||||
this.processing = true
|
||||
// 让 loading 渲染出来再执行,避免大文本时界面假死
|
||||
this.$nextTick(() => {
|
||||
window.setTimeout(() => {
|
||||
const indentValue = this.indent === 'tab' ? '\t' : this.indent
|
||||
const result = minify
|
||||
? minifyJson(source)
|
||||
: formatJson(source, { indent: indentValue, sortKeys: this.sortKeys })
|
||||
|
||||
if (result.ok) {
|
||||
// 变换成功后把原内容存入快照
|
||||
this.snapshot = source
|
||||
this.snapshotValid = true
|
||||
this.text = result.output
|
||||
this.stat = result.stat
|
||||
this.bigIntCount = result.bigIntCount || 0
|
||||
this.errorInfo = null
|
||||
this.$Message.success(
|
||||
(minify ? '压缩' : '格式化') + '完成,' +
|
||||
result.stat.keys + ' 个键 / ' + result.output.split('\n').length + ' 行'
|
||||
)
|
||||
} else {
|
||||
// 出错:保留用户原文,只更新错误信息
|
||||
this.errorInfo = result.error
|
||||
this.stat = null
|
||||
this.focusError()
|
||||
}
|
||||
this.processing = false
|
||||
}, 20)
|
||||
})
|
||||
},
|
||||
// 出错时把光标定位并选中出错附近文本,让用户一眼看到问题位置
|
||||
focusError () {
|
||||
this.$nextTick(() => {
|
||||
const editor = this.$refs.editor
|
||||
const textarea = editor && editor.$refs ? editor.$refs.textarea : null
|
||||
const el = textarea || (editor && editor.textarea)
|
||||
if (!el || !el.setSelectionRange) return
|
||||
const lines = this.text.split('\n')
|
||||
// 算出 (line, column) 对应的字符偏移
|
||||
let offset = 0
|
||||
for (let i = 0; i < this.errorInfo.line - 1; i++) {
|
||||
offset += lines[i].length + 1
|
||||
}
|
||||
offset += this.errorInfo.column - 1
|
||||
const end = Math.min(this.text.length, offset + 30)
|
||||
try {
|
||||
el.focus()
|
||||
el.setSelectionRange(offset, end)
|
||||
} catch (e) {
|
||||
// 某些环境下选区设置可能失败,忽略即可,不影响错误提示
|
||||
}
|
||||
})
|
||||
},
|
||||
doRestore () {
|
||||
if (!this.canRestore) return
|
||||
this.text = this.snapshot
|
||||
this.snapshotValid = false
|
||||
this.errorInfo = null
|
||||
this.stat = null
|
||||
this.$Message.info('已还原到变换前的内容')
|
||||
},
|
||||
onTextChanged () {
|
||||
// 用户手动编辑后,快照失效(无法还原到粘贴时的样子了)
|
||||
if (this.snapshotValid && this.text !== this.snapshot) {
|
||||
this.snapshotValid = false
|
||||
}
|
||||
if (this.errorInfo) {
|
||||
// 用户改了内容,重新校验一次,错误提示实时消失或刷新
|
||||
this.revalidate()
|
||||
}
|
||||
},
|
||||
// 轻量校验:只判断格式对错,不改动内容
|
||||
revalidate () {
|
||||
const result = formatJson(this.text, { indent: 0 })
|
||||
this.errorInfo = result.ok ? null : result.error
|
||||
this.stat = result.ok ? result.stat : null
|
||||
this.bigIntCount = result.ok ? (result.bigIntCount || 0) : 0
|
||||
},
|
||||
fillSample () {
|
||||
this.text = SAMPLE
|
||||
this.snapshot = ''
|
||||
this.snapshotValid = false
|
||||
this.errorInfo = null
|
||||
this.stat = null
|
||||
this.revalidate()
|
||||
},
|
||||
clearAll () {
|
||||
this.text = ''
|
||||
this.snapshot = ''
|
||||
this.snapshotValid = false
|
||||
this.errorInfo = null
|
||||
this.stat = null
|
||||
this.bigIntCount = 0
|
||||
},
|
||||
copyText () {
|
||||
if (!this.text) return
|
||||
const done = () => this.$Message.success('已复制到剪贴板')
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(this.text).then(done).catch(() => this.copyFallback())
|
||||
} else {
|
||||
this.copyFallback()
|
||||
}
|
||||
},
|
||||
// 非 https 环境 navigator.clipboard 不可用时降级
|
||||
copyFallback () {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = this.text
|
||||
el.style.position = 'fixed'
|
||||
el.style.opacity = '0'
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
this.$Message.success('已复制到剪贴板')
|
||||
} catch (e) {
|
||||
this.$Message.error('复制失败,请手动选中文本复制')
|
||||
}
|
||||
document.body.removeChild(el)
|
||||
},
|
||||
onDrop (event) {
|
||||
const dt = event.dataTransfer
|
||||
if (!dt || !dt.files || dt.files.length === 0) return
|
||||
const file = dt.files[0]
|
||||
const reader = new FileReader()
|
||||
reader.onload = e => {
|
||||
this.text = e.target.result
|
||||
this.snapshot = ''
|
||||
this.snapshotValid = false
|
||||
this.revalidate()
|
||||
this.$Message.success('已读取文件:' + file.name)
|
||||
}
|
||||
reader.readAsText(file, 'UTF-8')
|
||||
},
|
||||
onKeydown (event) {
|
||||
const isCtrl = event.ctrlKey || event.metaKey
|
||||
if (isCtrl && event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (!this.processing) this.doFormat()
|
||||
return
|
||||
}
|
||||
if (isCtrl && (event.key === 's' || event.key === 'S')) {
|
||||
event.preventDefault()
|
||||
if (!this.processing) this.doMinify()
|
||||
}
|
||||
}
|
||||
},
|
||||
// keep-alive 下 mounted 与 activated 都会触发,重复 addEventListener 会导致按键多次响应
|
||||
activated () {
|
||||
window.removeEventListener('keydown', this.onKeydown)
|
||||
window.addEventListener('keydown', this.onKeydown)
|
||||
},
|
||||
deactivated () {
|
||||
window.removeEventListener('keydown', this.onKeydown)
|
||||
},
|
||||
beforeDestroy () {
|
||||
window.removeEventListener('keydown', this.onKeydown)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.json-format-page {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.jf-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar-gap {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13px;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.indent-select {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
// 状态条:成功/错误两种态
|
||||
.jf-status {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e8eaec;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.jf-status.is-ok {
|
||||
background: #f7f8fa;
|
||||
}
|
||||
|
||||
.jf-status.has-error {
|
||||
background: #fdeeee;
|
||||
border-color: #f5c2c0;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
margin-top: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.is-ok .status-icon { color: #19be6b; }
|
||||
.has-error .status-icon { color: #ed4014; }
|
||||
.status-icon.idle { color: #808695; }
|
||||
|
||||
.ok-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ok-text {
|
||||
color: #19be6b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ok-text.idle-text {
|
||||
color: #808695;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 14px;
|
||||
background: #dcdee2;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
color: #808695;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-item b {
|
||||
color: #17233d;
|
||||
margin: 0 2px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.warn-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
// 错误详情
|
||||
.error-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-loc {
|
||||
color: #ed4014;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-loc b {
|
||||
font-size: 15px;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.error-reason {
|
||||
margin-left: 10px;
|
||||
color: #808695;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.error-preview {
|
||||
margin-top: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
border-left: 3px solid #ed4014;
|
||||
border-radius: 2px;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
color: #515a6e;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 66px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
// 输入区占满剩余空间
|
||||
.jf-input-wrap {
|
||||
flex: 1;
|
||||
margin-top: 12px;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.jf-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.jf-textarea /deep/ textarea {
|
||||
height: 100%;
|
||||
min-height: 320px;
|
||||
resize: none;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.jf-hint {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
padding-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #c5c8ce;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user