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
+335
View File
@@ -0,0 +1,335 @@
/**
* JSON 格式化工具核心库(零依赖)
*
* 设计要点:
* 1. 错误定位优先用 V8 新版报错自带的 (line X column Y),
* 旧版只给 position 时再按换行数换算——直接解析行列比换算更可靠
* 2. 解析时保留大整数的原始字面量,避免 JSON.stringify 把
* 超过 Number.MAX_SAFE_INTEGER 的整数转成科学计数法而丢精度
*/
// 超过这个体积就可能让页面卡住,先提示用户
export const LARGE_TEXT_BYTES = 2 * 1024 * 1024
// 大整数占位符前缀
const RAW_NUMBER_KEY = '@@__raw_num__'
/**
* 保留大整数的原始字面量
*
* JSON.parse 的第二个参数 reviver 在键已解析为数值后才被调用,
* 无法拿到原始文本。这里换一种思路:预先扫描出所有「键 -> 原始字面量」的映射,
* 序列化时用占位符替换,序列化完成后再把占位符换回原始字面量。
* 只处理不带小数点、不带指数的纯整数(即 JSON 中表示「可能超大」的数字字面量)。
*/
/**
* 把超出安全范围的整数替换为字符串占位,避免解析时丢精度
*
* 替换后的文本形如 {"id":"@@__raw_num__0"},序列化后 replace 掉引号即可还原。
* 原始字面量记录在返回值的 literals 里,由调用方在序列化后使用。
*
* @param {String} text
* @returns {{text:String, literals:Array, count:Number}}
*/
function protectBigIntegers (text) {
const literals = []
let inString = false
let quoteChar = ''
let escaped = false
let out = ''
for (let i = 0; i < text.length; i++) {
const ch = text[i]
if (inString) {
if (escaped) escaped = false
else if (ch === '\\') escaped = true
else if (ch === quoteChar) inString = false
out += ch
continue
}
if (ch === '"' || ch === '\'') {
inString = true
quoteChar = ch
out += ch
continue
}
if (ch >= '0' && ch <= '9') {
let j = i
while (j < text.length && text[j] >= '0' && text[j] <= '9') j++
const literal = text.slice(i, j)
// 16 位以内 JS 数字能精确表示,超过才需要保护
if (literal.length > 15) {
literals.push(literal)
out += '"' + RAW_NUMBER_KEY + (literals.length - 1) + '"'
} else {
out += literal
}
i = j - 1
continue
}
out += ch
}
return { text: out, literals: literals, count: literals.length }
}
/**
* 把序列化结果里的 "@@__raw_num__N" 占位串替换回原始数字(去掉引号)
* @param {String} output
* @param {Array} literals 原始数字字面量数组
* @returns {String}
*/
function restoreRawNumbers (output, literals) {
if (!literals || literals.length === 0) return output
return output.replace(/"@@__raw_num__(\d+)"/g, (match, idx) => {
const literal = literals[Number(idx)]
return literal === undefined ? match : literal
})
}
/**
* 把 position 换算成行列(position 从 0 起,行列从 1 起)
* @param {String} text
* @param {Number} position
* @returns {{line:Number, column:Number}}
*/
export function positionToLineColumn (text, position) {
const before = text.slice(0, Math.max(0, position))
const lines = before.split('\n')
return {
line: lines.length,
column: lines[lines.length - 1].length + 1
}
}
/**
* 从 V8 报错信息中解析出位置
*
* 新格式:'... in JSON at position 13 (line 3 column 3)' → 直接取行列
* 旧格式:'... in JSON at position 13' → 回退按换行换算
* 无位置:'Unexpected end of JSON input' → 取末尾
*
* @param {String} text 出错的原始文本
* @param {Error} error JSON.parse 抛出的异常
* @returns {{line:Number, column:Number, reason:String, hasPosition:Boolean}}
*/
export function locateError (text, error) {
const message = (error && error.message) || String(error || '解析失败')
// 优先取新版文案里的行列(最可靠)
const lineColMatch = message.match(/\(line\s+(\d+)\s+column\s+(\d+)\)/)
if (lineColMatch) {
return {
line: parseInt(lineColMatch[1], 10),
column: parseInt(lineColMatch[2], 10),
reason: stripPosition(message),
hasPosition: true
}
}
// 回退:从 position 换算
const posMatch = message.match(/position\s+(\d+)/)
if (posMatch) {
const pos = parseInt(posMatch[1], 10)
const lc = positionToLineColumn(text, pos)
return {
line: lc.line,
column: lc.column,
reason: stripPosition(message),
hasPosition: true
}
}
// 连 position 都没有(如「Unexpected end of JSON input」),定位到末尾
const lines = text.split('\n')
return {
line: lines.length,
column: lines[lines.length - 1].length + 1,
reason: message,
hasPosition: false
}
}
/**
* 去掉报错文案里的 position/行列尾巴,得到干净的错误描述
* @param {String} message
* @returns {String}
*/
function stripPosition (message) {
return message
.replace(/\s*in JSON at position\s+\d+(\s*\(line\s+\d+\s+column\s+\d+\))?/, '')
.replace(/\s*\(line\s+\d+\s+column\s+\d+\)/, '')
.trim()
}
/**
* 键名排序:递归对对象的键排序(数组保持原顺序)
* @param {*} value
* @returns {*} 排序后的新对象
*/
export function sortKeysDeep (value) {
if (Array.isArray(value)) {
return value.map(sortKeysDeep)
}
if (value && typeof value === 'object') {
const out = {}
Object.keys(value).sort().forEach(key => {
out[key] = sortKeysDeep(value[key])
})
return out
}
return value
}
/**
* 统计对象结构信息
* @param {*} value 已解析的对象
* @returns {{keys:Number, values:Number, maxDepth:Number, arrays:Number}}
*/
export function analyze (value) {
const stat = { keys: 0, values: 0, maxDepth: 0, arrays: 0 }
const walk = (node, depth) => {
if (depth > stat.maxDepth) stat.maxDepth = depth
if (Array.isArray(node)) {
stat.arrays++
node.forEach(item => {
stat.values++
walk(item, depth + 1)
})
return
}
if (node && typeof node === 'object') {
Object.keys(node).forEach(key => {
stat.keys++
stat.values++
walk(node[key], depth + 1)
})
}
}
walk(value, 0)
return stat
}
/**
* 序列化(可选排序键名)
* @param {Object} data 已解析的对象
* @param {Number} indent 缩进空格数,0 表示压缩
* @param {Boolean} sortKeys 是否按键名排序
* @returns {String}
*/
function serialize (data, indent, sortKeys) {
const target = sortKeys ? sortKeysDeep(data) : data
return JSON.stringify(target, null, indent)
}
/**
* 格式化 JSON
* @param {String} text 原始 JSON 文本
* @param {Object} options
* @param {Number} options.indent 缩进空格数,默认 2
* @param {Boolean} options.sortKeys 是否按键名排序,默认 false
* @returns {{ok:Boolean, output:String, error:Object, stat:Object}}
*/
export function formatJson (text, options) {
const opts = options || {}
const indent = opts.indent === undefined ? 2 : opts.indent
const sortKeys = !!opts.sortKeys
return process(text, indent, sortKeys)
}
/**
* 压缩 JSON(去掉所有缩进与换行)
* @param {String} text 原始 JSON 文本
* @returns {{ok:Boolean, output:String, error:Object, stat:Object}}
*/
export function minifyJson (text) {
return process(text, 0, false)
}
/**
* 格式化的公共流程:保护大整数 → 解析 → 序列化 → 还原大整数
* @param {String} text
* @param {Number} indent 0 表示压缩
* @param {Boolean} sortKeys
* @returns {Object}
*/
function process (text, indent, sortKeys) {
const source = text == null ? '' : String(text)
if (source.trim() === '') {
return {
ok: false,
output: '',
error: { line: 1, column: 1, reason: '内容为空', hasPosition: false },
stat: null
}
}
const protectedText = protectBigIntegers(source)
let parsed
try {
parsed = JSON.parse(protectedText.text)
} catch (e) {
// 出错时定位要用「未保护」的原文,列号才对得上
return {
ok: false,
output: '',
error: locateError(source, e),
stat: null
}
}
let output
try {
output = serialize(parsed, indent, sortKeys)
} catch (e) {
return {
ok: false,
output: '',
error: { line: 1, column: 1, reason: '序列化失败:' + (e && e.message), hasPosition: false },
stat: null
}
}
// 把占位符还原成原始数字字面量(不带引号)
if (protectedText.count > 0) {
output = restoreRawNumbers(output, protectedText.literals)
}
const stat = analyze(parsed)
return {
ok: true,
output: output,
error: null,
stat: stat,
bigIntCount: protectedText.count
}
}
/**
* 统计文本体积(字节)
* @param {String} text
* @returns {Number}
*/
export function byteSize (text) {
if (!text) return 0
// 估算:中文按 3 字节计(UTF-8)
let bytes = 0
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i)
if (code < 128) bytes += 1
else if (code < 2048) bytes += 2
else bytes += 3
}
return bytes
}
/**
* 人类可读的体积
* @param {Number} bytes
* @returns {String}
*/
export function formatSize (bytes) {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(2) + ' MB'
}
+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>