- 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,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'
|
||||||
|
}
|
||||||
@@ -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