* 新增:JSON 文件格式化预览功能

* 优化:优化 JSON 文件格式化预览效果
This commit is contained in:
kl
2025-10-11 17:54:17 +08:00
committed by GitHub
parent a9787b0add
commit 51653483b9
3 changed files with 286 additions and 52 deletions

View File

@ -1,9 +1,22 @@
package cn.keking.service.impl;
import cn.keking.config.ConfigConstants;
import cn.keking.model.FileAttribute;
import cn.keking.model.ReturnResponse;
import cn.keking.service.FileHandlerService;
import cn.keking.service.FilePreview;
import cn.keking.utils.DownloadUtils;
import cn.keking.utils.KkFileUtils;
import org.apache.commons.codec.binary.Base64;
import org.springframework.stereotype.Service;
import org.springframework.ui.Model;
import org.springframework.web.util.HtmlUtils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
/**
* @author kl (http://kailing.pub)
@ -13,15 +26,66 @@ import org.springframework.ui.Model;
@Service
public class JsonFilePreviewImpl implements FilePreview {
private final SimTextFilePreviewImpl simTextFilePreview;
private final FileHandlerService fileHandlerService;
private final OtherFilePreviewImpl otherFilePreview;
public JsonFilePreviewImpl(SimTextFilePreviewImpl simTextFilePreview) {
this.simTextFilePreview = simTextFilePreview;
public JsonFilePreviewImpl(FileHandlerService fileHandlerService, OtherFilePreviewImpl otherFilePreview) {
this.fileHandlerService = fileHandlerService;
this.otherFilePreview = otherFilePreview;
}
@Override
public String filePreviewHandle(String url, Model model, FileAttribute fileAttribute) {
simTextFilePreview.filePreviewHandle(url, model, fileAttribute);
String fileName = fileAttribute.getName();
boolean forceUpdatedCache = fileAttribute.forceUpdatedCache();
String filePath = fileAttribute.getOriginFilePath();
if (forceUpdatedCache || !fileHandlerService.listConvertedFiles().containsKey(fileName) || !ConfigConstants.isCacheEnabled()) {
ReturnResponse<String> response = DownloadUtils.downLoad(fileAttribute, fileName);
if (response.isFailure()) {
return otherFilePreview.notSupportedFile(model, fileAttribute, response.getMsg());
}
filePath = response.getContent();
if (ConfigConstants.isCacheEnabled()) {
fileHandlerService.addConvertedFile(fileName, filePath);
}
try {
String fileData = readJsonFile(filePath, fileName);
String escapedData = HtmlUtils.htmlEscape(fileData);
String base64Data = Base64.encodeBase64String(escapedData.getBytes(StandardCharsets.UTF_8));
model.addAttribute("textData", base64Data);
} catch (IOException e) {
return otherFilePreview.notSupportedFile(model, fileAttribute, e.getLocalizedMessage());
}
return JSON_FILE_PREVIEW_PAGE;
}
String fileData = null;
try {
fileData = HtmlUtils.htmlEscape(readJsonFile(filePath, fileName));
} catch (IOException e) {
e.printStackTrace();
}
String base64Data = Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8));
model.addAttribute("textData", base64Data);
return JSON_FILE_PREVIEW_PAGE;
}
/**
* 读取 JSON 文件,强制使用 UTF-8 编码
* JSON 标准规定必须使用 UTF-8 编码
*/
private String readJsonFile(String filePath, String fileName) throws IOException {
File file = new File(filePath);
if (KkFileUtils.isIllegalFileName(fileName)) {
return null;
}
if (!file.exists() || file.length() == 0) {
return "";
}
// JSON 标准规定使用 UTF-8 编码,不依赖自动检测
byte[] bytes = Files.readAllBytes(Paths.get(filePath));
return new String(bytes, StandardCharsets.UTF_8);
}
}

View File

@ -47,7 +47,7 @@ public class SimTextFilePreviewImpl implements FilePreview {
}
try {
String fileData = HtmlUtils.htmlEscape(textData(filePath,fileName));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes()));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8)));
} catch (IOException e) {
return otherFilePreview.notSupportedFile(model, fileAttribute, e.getLocalizedMessage());
}
@ -59,7 +59,7 @@ public class SimTextFilePreviewImpl implements FilePreview {
} catch (IOException e) {
e.printStackTrace();
}
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes()));
model.addAttribute("textData", Base64.encodeBase64String(fileData.getBytes(StandardCharsets.UTF_8)));
return TXT_FILE_PREVIEW_PAGE;
}

View File

@ -17,25 +17,18 @@
max-width: 100%;
padding: 20px;
}
.panel-body {
padding: 0;
}
#json {
padding: 20px;
background-color: #f8f9fa;
overflow-x: auto;
}
#text_view {
padding: 20px;
background-color: #ffffff;
padding: 0;
overflow-x: auto;
}
pre {
margin: 0;
padding: 20px;
padding-left: 65px; /* 为行号留出空间 */
white-space: pre-wrap;
word-wrap: break-word;
font-size: 14px;
line-height: 1.6;
position: relative;
}
.json-key {
color: #881391;
@ -55,11 +48,31 @@
color: #808080;
font-weight: bold;
}
.btn-group {
margin-bottom: 10px;
.json-toggle {
cursor: pointer;
color: #666;
user-select: none;
display: inline-block;
width: 16px;
font-weight: bold;
}
.view-mode-btn {
min-width: 100px;
.json-toggle:hover {
color: #333;
}
.json-node {
display: block;
}
.line-number {
position: absolute;
left: 0;
width: 55px;
color: #999;
font-size: 12px;
user-select: none;
text-align: right;
padding-right: 10px;
border-right: 1px solid #ddd;
background-color: #f8f9fa;
}
</style>
</head>
@ -68,18 +81,21 @@
<input hidden id="textData" value="${textData}"/>
<div class="container">
<div class="panel panel-default">
<div class="panel-heading">
<div id="formatted_btn" class="panel-heading">
<h4 class="panel-title">
${file.name}
<a data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
${file.name}
</a>
</h4>
<div class="btn-group" role="group">
<button type="button" class="btn btn-primary view-mode-btn" id="formatted_btn">格式化视图</button>
<button type="button" class="btn btn-default view-mode-btn" id="raw_btn">原始文本</button>
</div>
</div>
<div class="panel-body">
<div id="json"></div>
<div id="text_view" style="display:none;"></div>
<div id="raw_btn" class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
${file.name}
</a>
</h4>
</div>
<div id="json" class="panel-body">
</div>
</div>
</div>
@ -89,22 +105,20 @@
* 初始化
*/
window.onload = function () {
$("#formatted_btn").hide();
initWaterMark();
loadJsonData();
}
/**
* HTML 反转义(用于还原后端转义的内容)
* 使用浏览器的 DOM 来正确解码所有 HTML 实体
*/
function htmlUnescape(str) {
if (!str || str.length === 0) return "";
var s = str;
s = s.replace(/&quot;/g, '"');
s = s.replace(/&#39;/g, "'");
s = s.replace(/&lt;/g, "<");
s = s.replace(/&gt;/g, ">");
s = s.replace(/&amp;/g, "&");
return s;
var textarea = document.createElement('textarea');
textarea.innerHTML = str;
return textarea.value;
}
/**
@ -131,8 +145,132 @@
return str;
}
// 全局行号计数器
var lineNumber = 1;
/**
* JSON 语法高亮
* 构建可展开/收起的 JSON 树形结构
*/
function buildJsonTree(obj, indent, skipLineNumber) {
indent = indent || 0;
skipLineNumber = skipLineNumber || false;
var html = '';
var indentStr = ' '.repeat(indent);
if (obj === null) {
return '<span class="json-null">null</span>';
}
if (typeof obj !== 'object') {
if (typeof obj === 'string') {
// 转义特殊字符,避免换行和制表符破坏布局
var escapedStr = obj
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
.replace(/\t/g, '\\t')
.replace(/"/g, '\\"');
return '<span class="json-string">"' + htmlEscape(escapedStr) + '"</span>';
} else if (typeof obj === 'number') {
return '<span class="json-number">' + obj + '</span>';
} else if (typeof obj === 'boolean') {
return '<span class="json-boolean">' + obj + '</span>';
}
return htmlEscape(String(obj));
}
var isArray = Array.isArray(obj);
var entries = isArray ? obj : Object.keys(obj);
var openBracket = isArray ? '[' : '{';
var closeBracket = isArray ? ']' : '}';
if (entries.length === 0) {
return openBracket + closeBracket;
}
var nodeId = 'node_' + Math.random().toString(36).substr(2, 9);
// 如果不跳过行号,说明是新的一行
if (!skipLineNumber) {
html += '<span class="line-number">' + lineNumber++ + '</span>';
}
html += '<span class="json-toggle" onclick="toggleJsonNode(\'' + nodeId + '\')">▼</span> ';
html += openBracket + '\n';
html += '<div id="' + nodeId + '" class="json-node">';
for (var i = 0; i < entries.length; i++) {
var key = isArray ? i : entries[i];
var value = isArray ? entries[i] : obj[entries[i]];
html += '<span class="line-number">' + lineNumber++ + '</span>';
html += indentStr + ' ';
if (!isArray) {
html += '<span class="json-key">"' + htmlEscape(key) + '"</span>: ';
}
// 如果值是对象或数组,跳过它的行号(因为已经在上面添加了)
html += buildJsonTree(value, indent + 1, true);
if (i < entries.length - 1) {
html += ',';
}
html += '\n';
}
html += '</div>';
html += '<span class="line-number">' + lineNumber++ + '</span>';
html += indentStr + closeBracket;
return html;
}
/**
* 切换 JSON 节点展开/收起
*/
function toggleJsonNode(nodeId) {
var node = document.getElementById(nodeId);
var toggle = event.target;
if (node.style.display === 'none') {
node.style.display = 'block';
toggle.textContent = '▼';
} else {
node.style.display = 'none';
toggle.textContent = '▶';
}
}
/**
* 全部展开
*/
function expandAll() {
var nodes = document.querySelectorAll('.json-node');
var toggles = document.querySelectorAll('.json-toggle');
nodes.forEach(function(node) {
node.style.display = 'block';
});
toggles.forEach(function(toggle) {
toggle.textContent = '▼';
});
}
/**
* 全部收起
*/
function collapseAll() {
var nodes = document.querySelectorAll('.json-node');
var toggles = document.querySelectorAll('.json-toggle');
nodes.forEach(function(node) {
node.style.display = 'none';
});
toggles.forEach(function(toggle) {
toggle.textContent = '▶';
});
}
/**
* JSON 语法高亮(简单版本,用于原始文本视图)
*/
function syntaxHighlight(json) {
json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@ -153,12 +291,36 @@
});
}
/**
* UTF-8 解码 Base64正确处理中文等 UTF-8 字符)
*/
function decodeBase64UTF8(base64Str) {
try {
// 方法1使用现代浏览器的 TextDecoder API推荐
if (typeof TextDecoder !== 'undefined') {
var binaryString = window.atob(base64Str);
var bytes = new Uint8Array(binaryString.length);
for (var i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return new TextDecoder('utf-8').decode(bytes);
}
// 方法2降级方案
return decodeURIComponent(escape(window.atob(base64Str)));
} catch (e) {
console.error('Base64 decode error:', e);
// 最后降级到 Base64.js 库
return Base64.decode(base64Str);
}
}
/**
* 加载 JSON 数据
*/
function loadJsonData() {
try {
var textData = Base64.decode($("#textData").val());
var textData = decodeBase64UTF8($("#textData").val());
// 1. 先反转义 HTML 实体(因为后端已经转义过)
textData = htmlUnescape(textData);
@ -167,15 +329,27 @@
textData = removeBOM(textData);
// 保存原始文本(用于显示时再次转义以保证安全)
window.rawText = "<pre>" + htmlEscape(textData) + "</pre>";
window.rawText = "<pre style='background-color: #FFFFFF; border: none; margin: 0;'>" + htmlEscape(textData) + "</pre>";
// 尝试解析并格式化 JSON
try {
var jsonObj = JSON.parse(textData);
var formattedJson = JSON.stringify(jsonObj, null, 4);
window.formattedJson = "<pre>" + syntaxHighlight(formattedJson) + "</pre>";
// 默认显示格式化视图
// 重置行号计数器
lineNumber = 1;
// 构建树形视图
var treeHtml = '<div style="padding: 20px;">';
treeHtml += '<div style="margin-bottom: 10px;">';
treeHtml += '<button onclick="expandAll()" class="btn btn-sm btn-default" style="margin-right: 5px;">全部展开</button>';
treeHtml += '<button onclick="collapseAll()" class="btn btn-sm btn-default">全部收起</button>';
treeHtml += '</div>';
treeHtml += '<pre style="background-color: #f8f9fa; border: none; margin: 0;">';
treeHtml += buildJsonTree(jsonObj, 0);
treeHtml += '</pre></div>';
window.formattedJson = treeHtml;
// 默认显示树形视图
$("#json").html(window.formattedJson);
} catch (e) {
// 如果不是有效的 JSON显示错误并回退到原始文本
@ -193,19 +367,15 @@
*/
$(function () {
$("#formatted_btn").click(function () {
$("#json").show();
$("#text_view").hide();
$("#json").html(window.formattedJson);
$("#formatted_btn").removeClass("btn-default").addClass("btn-primary");
$("#raw_btn").removeClass("btn-primary").addClass("btn-default");
$("#raw_btn").show();
$("#formatted_btn").hide();
});
$("#raw_btn").click(function () {
$("#json").hide();
$("#text_view").show();
$("#text_view").html(window.rawText);
$("#raw_btn").removeClass("btn-default").addClass("btn-primary");
$("#formatted_btn").removeClass("btn-primary").addClass("btn-default");
$("#json").html(window.rawText);
$("#formatted_btn").show();
$("#raw_btn").hide();
});
});
</script>