feat(tools): 新增 JSON 格式化工具页面
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 1m27s

- 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:
zhangjiaxun
2026-10-20 23:00:31 +08:00
parent 84b6933657
commit 8fb8154820
2 changed files with 858 additions and 0 deletions
+523
View File
@@ -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>