From 51653483b916efe86ac6164ea8f0e1defda61a9a Mon Sep 17 00:00:00 2001 From: kl <632104866@QQ.com> Date: Sat, 11 Oct 2025 17:54:17 +0800 Subject: [PATCH] Kl json (#686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增:JSON 文件格式化预览功能 * 优化:优化 JSON 文件格式化预览效果 --- .../service/impl/JsonFilePreviewImpl.java | 72 ++++- .../service/impl/SimTextFilePreviewImpl.java | 4 +- server/src/main/resources/web/json.ftl | 262 +++++++++++++++--- 3 files changed, 286 insertions(+), 52 deletions(-) diff --git a/server/src/main/java/cn/keking/service/impl/JsonFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/JsonFilePreviewImpl.java index 57a653fd..b5d15714 100644 --- a/server/src/main/java/cn/keking/service/impl/JsonFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/JsonFilePreviewImpl.java @@ -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 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); + } } diff --git a/server/src/main/java/cn/keking/service/impl/SimTextFilePreviewImpl.java b/server/src/main/java/cn/keking/service/impl/SimTextFilePreviewImpl.java index 9543abb5..ce7c6d67 100644 --- a/server/src/main/java/cn/keking/service/impl/SimTextFilePreviewImpl.java +++ b/server/src/main/java/cn/keking/service/impl/SimTextFilePreviewImpl.java @@ -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; } diff --git a/server/src/main/resources/web/json.ftl b/server/src/main/resources/web/json.ftl index efca0e1e..16a64fb6 100644 --- a/server/src/main/resources/web/json.ftl +++ b/server/src/main/resources/web/json.ftl @@ -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; } @@ -68,18 +81,21 @@
-
+

- ${file.name} + + ${file.name} +

-
- - -
-
-
- + +
@@ -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(/"/g, '"'); - s = s.replace(/'/g, "'"); - s = s.replace(/</g, "<"); - s = s.replace(/>/g, ">"); - s = s.replace(/&/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 'null'; + } + + 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 '"' + htmlEscape(escapedStr) + '"'; + } else if (typeof obj === 'number') { + return '' + obj + ''; + } else if (typeof obj === 'boolean') { + return '' + obj + ''; + } + 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 += '' + lineNumber++ + ''; + } + + html += ' '; + html += openBracket + '\n'; + html += '
'; + + for (var i = 0; i < entries.length; i++) { + var key = isArray ? i : entries[i]; + var value = isArray ? entries[i] : obj[entries[i]]; + + html += '' + lineNumber++ + ''; + html += indentStr + ' '; + if (!isArray) { + html += '"' + htmlEscape(key) + '": '; + } + + // 如果值是对象或数组,跳过它的行号(因为已经在上面添加了) + html += buildJsonTree(value, indent + 1, true); + + if (i < entries.length - 1) { + html += ','; + } + html += '\n'; + } + + html += '
'; + html += '' + lineNumber++ + ''; + 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, '&').replace(//g, '>'); @@ -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 = "
" + htmlEscape(textData) + "
"; + window.rawText = "
" + htmlEscape(textData) + "
"; // 尝试解析并格式化 JSON try { var jsonObj = JSON.parse(textData); - var formattedJson = JSON.stringify(jsonObj, null, 4); - window.formattedJson = "
" + syntaxHighlight(formattedJson) + "
"; - // 默认显示格式化视图 + // 重置行号计数器 + lineNumber = 1; + + // 构建树形视图 + var treeHtml = '
'; + treeHtml += '
'; + treeHtml += ''; + treeHtml += ''; + treeHtml += '
'; + treeHtml += '
';
+                treeHtml += buildJsonTree(jsonObj, 0);
+                treeHtml += '
'; + 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(); }); });