From 476141ebdc2074a7772cd36f4d98c1f8f05427ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 28 Jul 2026 15:56:01 +0800 Subject: [PATCH] =?UTF-8?q?api=E7=94=9F=E6=88=90=E5=99=A8=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workutils/biz/apidoc/ApiDocWindow.java | 57 +- .../biz/apidoc/generator/ApiDocGenerator.java | 1056 ++++------------- .../apidoc/generator/DocRenderContext.java | 60 + .../apidoc/generator/HtmlResourceLoader.java | 69 ++ .../resources/templates/apidoc/api-doc.css | 188 +++ .../resources/templates/apidoc/api-doc.html | 43 + .../resources/templates/apidoc/api-doc.js | 272 +++++ 7 files changed, 844 insertions(+), 901 deletions(-) create mode 100644 src/main/java/com/example/workutils/biz/apidoc/generator/DocRenderContext.java create mode 100644 src/main/java/com/example/workutils/biz/apidoc/generator/HtmlResourceLoader.java create mode 100644 src/main/resources/templates/apidoc/api-doc.css create mode 100644 src/main/resources/templates/apidoc/api-doc.html create mode 100644 src/main/resources/templates/apidoc/api-doc.js diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java index 2b8e058..ed08f37 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java @@ -65,10 +65,6 @@ public class ApiDocWindow { private CheckBox apiPackageOnlyCb; private CheckBox facadeNameCb; - // 导出格式 - private ToggleGroup fmtGroup; - private RadioButton rbHtml, rbMd; - // 操作区 private Button exportBtn; private ProgressBar progressBar; @@ -219,28 +215,6 @@ public class ApiDocWindow { fqnPanel.visibleProperty().bind(rbFqn.selectedProperty()); batchPanel.visibleProperty().bind(rbBatch.selectedProperty()); - // --- 分割线 --- - Separator sep1 = new Separator(); - - // --- 导出格式 --- - HBox fmtRow = new HBox(Theme.S4); - fmtRow.setAlignment(Pos.CENTER_LEFT); - Label fmtLabel = new Label("导出格式:"); - fmtLabel.setFont(Theme.base(fsBody())); - fmtLabel.setTextFill(Theme.TEXT_PRIMARY); - fmtGroup = new ToggleGroup(); - rbHtml = new RadioButton("HTML"); - rbMd = new RadioButton("Markdown"); - rbHtml.setToggleGroup(fmtGroup); - rbMd.setToggleGroup(fmtGroup); - rbHtml.setSelected(true); - rbHtml.setFont(Theme.base(fsBody() - 1)); - rbMd.setFont(Theme.base(fsBody() - 1)); - fmtRow.getChildren().addAll(fmtLabel, rbHtml, rbMd); - - // --- 分割线 --- - Separator sep2 = new Separator(); - // --- 操作按钮 --- HBox actionRow = new HBox(Theme.S3); actionRow.setAlignment(Pos.CENTER_LEFT); @@ -258,7 +232,7 @@ public class ApiDocWindow { actionRow.getChildren().addAll(exportBtn, progressBar, progressLabel); - configCard.getChildren().addAll(modeLabel, modeRow, inputStack, sep1, fmtRow, sep2, actionRow); + configCard.getChildren().addAll(modeLabel, modeRow, inputStack, actionRow); // ===================== 历史记录 ===================== Label histTitle = new Label("导出历史"); @@ -371,10 +345,6 @@ public class ApiDocWindow { }, "ApiDoc-Worker").start(); } - private String getFmt() { - return rbHtml.isSelected() ? "1" : "2"; - } - // ==================== 单文件导出 ==================== private void exportSingle(String inputPath, Path filePath, JavaSourceParser parser) throws Exception { @@ -593,24 +563,13 @@ public class ApiDocWindow { sel.scope = ApiDocModel.ExportScope.CUSTOM; sel.projectName = name; - String fmt = getFmt(); - if ("1".equals(fmt)) { - Path p = gen.generateHtml(sel, docs); - String filePath = p.toAbsolutePath().toString(); - Platform.runLater(() -> { - log("HTML 已导出: " + filePath); - addHistory("HTML", filePath); - enableBtn(); - }); - } else { - Path p = gen.generateMarkdown(sel, docs); - String filePath = p.toAbsolutePath().toString(); - Platform.runLater(() -> { - log("Markdown 已导出: " + filePath); - addHistory("Markdown", filePath); - enableBtn(); - }); - } + Path p = gen.generateHtml(sel, docs); + String filePath = p.toAbsolutePath().toString(); + Platform.runLater(() -> { + log("HTML 已导出: " + filePath); + addHistory("HTML", filePath); + enableBtn(); + }); } private void addHistory(String mode, String filePath) { diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java index 2ec0737..2afff91 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java @@ -11,9 +11,12 @@ import java.nio.file.Path; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; /** * API 文档生成器 —— 输出 HTML / Markdown 格式 @@ -22,10 +25,12 @@ import java.util.Map; public class ApiDocGenerator { private final WorkUtilsProperties properties; + private final HtmlResourceLoader resourceLoader; private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); public ApiDocGenerator(WorkUtilsProperties properties) { this.properties = properties; + this.resourceLoader = new HtmlResourceLoader(); } /** @@ -43,66 +48,49 @@ public class ApiDocGenerator { return outputPath; } - /** - * 导出 Markdown 格式文档 - */ - public Path generateMarkdown(ApiDocModel.ExportSelection selection, - List docs) throws IOException { - String fileName = buildFileName(selection, "md"); - Path outputPath = properties.resolveExportPath(fileName); - Files.createDirectories(outputPath.getParent()); - - try (BufferedWriter w = Files.newBufferedWriter(outputPath, java.nio.charset.StandardCharsets.UTF_8)) { - w.write(buildMarkdown(selection, docs)); - } - return outputPath; - } - // ==================== HTML 构建 ==================== + /** + * 构建完整 HTML 文档。 + * 加载模板文件并通过占位符替换注入动态内容。 + * 对应模板:resources/templates/apidoc/api-doc.html + */ private String buildHtml(ApiDocModel.ExportSelection selection, List docs) { - StringBuilder sb = new StringBuilder(); - // 计算统计数据 int totalClasses = 0, totalMethods = 0; for (ApiDocModel.ProjectApiDoc d : docs) { totalClasses += d.classes.size(); totalMethods += d.totalMethods(); } - sb.append("\n\n\n"); - sb.append("\n"); - sb.append("\n"); - sb.append("API 接口文档\n"); - appendCss(sb); - // 图片复制依赖 html2canvas(联网时加载;离线自动降级为富文本) - sb.append("\n"); - sb.append("\n\n"); + String template = resourceLoader.loadTemplate(); - // 顶部栏(含搜索) - sb.append("
\n"); - sb.append("
📄 API 接口文档
\n"); - sb.append("
\n"); - sb.append(" 接口文档\n"); - sb.append("
\n"); - sb.append(" \n"); - sb.append("
生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("
\n"); - sb.append("
\n"); + return template + .replace("{{CSS}}", resourceLoader.loadCss()) + .replace("{{JS}}", resourceLoader.loadJs()) + .replace("{{GENERATE_TIME}}", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) + .replace("{{STATS_BAR}}", buildStatsBar(docs.size(), totalClasses, totalMethods)) + .replace("{{SIDEBAR}}", buildSidebar(docs)) + .replace("{{CONTENT}}", buildContent(docs)); + } - // 统计摘要栏 + // ---------- 统计栏 ---------- + + private String buildStatsBar(int moduleCount, int classCount, int methodCount) { + StringBuilder sb = new StringBuilder(300); sb.append("
\n"); - sb.append(" ").append(docs.size()).append(" 模块\n"); - sb.append(" ").append(totalClasses).append(" 接口/类\n"); - sb.append(" ").append(totalMethods).append(" 方法\n"); + sb.append(" ").append(moduleCount).append(" 模块\n"); + sb.append(" ").append(classCount).append(" 接口/类\n"); + sb.append(" ").append(methodCount).append(" 方法\n"); sb.append(" \n"); sb.append(" \n"); sb.append(" \n"); sb.append(" \n"); sb.append("
\n"); + return sb.toString(); + } - // 左侧导航 + 右侧内容 - sb.append("
\n"); - sb.append("\n"); + return sb.toString(); + } - // 右侧内容区 - sb.append("
\n"); + // ---------- 右侧内容 ---------- + private String buildContent(List docs) { + StringBuilder sb = new StringBuilder(5000); for (ApiDocModel.ProjectApiDoc doc : docs) { sb.append("
\n"); sb.append("

").append(escapeHtml(doc.projectName)).append("

\n"); - for (ApiDocModel.ClassInfo ci : doc.classes) { - String anchor = anchorId(doc.projectName, ci.fullClassName); - sb.append("
\n"); - - // 基本信息区 - sb.append("
\n"); - sb.append("
\n"); - String typeLabel = "interface".equals(ci.type) ? "接口" : "class".equals(ci.type) ? "类" : ci.type; - sb.append(" ").append(typeLabel).append("\n"); - sb.append(" ").append(escapeHtml(ci.className)).append("\n"); - sb.append("
\n"); - if (ci.methods != null) { - sb.append("
"); - sb.append(" "); - sb.append(" "); - sb.append("").append(ci.methods.size()).append(" 个方法
\n"); - } - sb.append("
\n"); - - // 基本信息表格 - sb.append("
\n"); - sb.append("
全限定名").append(escapeHtml(ci.fullClassName)).append("
\n"); - if (ci.superClass != null) { - sb.append("
父类").append(escapeHtml(ci.superClass)).append("
\n"); - } - if (ci.interfaces != null && !ci.interfaces.isEmpty()) { - sb.append("
实现接口").append(escapeHtml(String.join(", ", ci.interfaces))).append("
\n"); - } - if (ci.annotations != null && !ci.annotations.isEmpty()) { - sb.append("
注解"); - for (String a : ci.annotations) { - sb.append("").append(escapeHtml(a)).append(""); - } - sb.append("
\n"); - } - if (ci.javadoc != null && !ci.javadoc.isEmpty()) { - sb.append("
描述").append(escapeHtml(ci.javadoc)).append("
\n"); - } - sb.append("
\n"); - - // 方法列表 - if (ci.methods != null) { - sb.append("
\n"); - sb.append("
📋 接口方法
\n"); - for (ApiDocModel.MethodInfo mi : ci.methods) { - String methodId = anchor + "_" + safeId(mi.name); - sb.append("
\n"); - - // 方法头 - sb.append("
\n"); - sb.append(" ▼\n"); - String httpMethod = extractHttpMethod(mi.annotations); - if (!httpMethod.isEmpty()) { - sb.append(" ").append(httpMethod).append("\n"); - } - sb.append(" ").append(escapeHtml(mi.name)).append("\n"); - if (mi.returnType != null) { - sb.append(" → ").append(escapeHtml(mi.returnType)).append("\n"); - } - sb.append(" \n"); - sb.append(" \n"); - sb.append("
\n"); - - // 方法体 - sb.append("
\n"); - - // 注解 - if (mi.annotations != null && !mi.annotations.isEmpty()) { - sb.append("
\n"); - for (String a : mi.annotations) { - sb.append(" ").append(escapeHtml(a)).append("\n"); - } - sb.append("
\n"); - } - - // 描述 - if (mi.javadoc != null && !mi.javadoc.isEmpty()) { - sb.append("
").append(escapeHtml(mi.javadoc)).append("
\n"); - } - - // 签名(带复制按钮) - sb.append("
").append(escapeHtml(mi.fullSignature())).append("
\n"); - - // 方法参数 - if (mi.params != null && !mi.params.isEmpty()) { - sb.append("
\n"); - sb.append("
方法参数
\n"); - sb.append(" \n"); - for (ApiDocModel.ParamInfo pi : mi.params) { - sb.append(" ") - .append("") - .append("\n"); - } - sb.append("
参数名类型全路径
").append(escapeHtml(pi.name)).append("").append(escapeHtml(pi.type)).append("").append(escapeHtml(pi.fullQualifiedName != null ? pi.fullQualifiedName : "")).append("
\n
\n"); - } - - // 请求 DTO - if (mi.requestFields != null && !mi.requestFields.isEmpty()) { - renderFieldsHtmlV2(sb, mi.requestFields, "请求参数", 0); - } - - // 返回包装器(始终先展示) - boolean wrapperHasFields = mi.responseWrapperFields != null && !mi.responseWrapperFields.isEmpty(); - if (mi.responseWrapperInheritedFrom != null) { - if (wrapperHasFields) { - renderFieldsHtmlV2(sb, mi.responseWrapperFields, - "返回结果 · 包装器 " + mi.responseWrapperInheritedFrom, 0); - } else { - // 未配置的外部类,醒目提示 - sb.append("
\n"); - sb.append("
") - .append("⚠️ 返回结果 · 包装器 ").append(escapeHtml(mi.responseWrapperInheritedFrom)).append("") - .append(" 未配置 · 请在 api_base_classes.json 中补充") - .append("
\n"); - sb.append("
\n"); - } - } - - // 返回 DTO(泛型内层 T 的字段) - if (mi.responseFields != null && !mi.responseFields.isEmpty()) { - String respTitle = mi.responseWrapperInheritedFrom != null - ? "返回结果 · " + mi.responseInnerType - : "返回结果"; - renderFieldsHtmlV2(sb, mi.responseFields, respTitle, 0); - } - - // throws - if (mi.throwsTypes != null && !mi.throwsTypes.isEmpty()) { - sb.append("
⚠ throws: ") - .append(escapeHtml(String.join(", ", mi.throwsTypes))) - .append("
\n"); - } - - sb.append("
\n"); // .method-body - sb.append("
\n"); // .method-card - } - sb.append("
\n"); // .methods-section - } - - sb.append("
\n"); // .api-card + sb.append(buildClassCard(doc.projectName, ci)); } sb.append("
\n"); } + return sb.toString(); + } - sb.append("
\n"); - sb.append("
\n"); // .main-layout + /** 构建单个类/接口卡片 */ + private String buildClassCard(String projectName, ApiDocModel.ClassInfo ci) { + String anchor = anchorId(projectName, ci.fullClassName); + StringBuilder sb = new StringBuilder(3000); + sb.append("
\n"); - // JS: 折叠/展开 + 侧栏导航 - appendJs(sb); + // 头 + sb.append("
\n"); + sb.append("
\n"); + String typeLabel = "interface".equals(ci.type) ? "接口" : "class".equals(ci.type) ? "类" : ci.type; + sb.append(" ").append(typeLabel).append("\n"); + sb.append(" ").append(escapeHtml(ci.className)).append("\n"); + sb.append("
\n"); + if (ci.methods != null) { + sb.append("
"); + sb.append(" "); + sb.append(" "); + sb.append("").append(ci.methods.size()).append(" 个方法
\n"); + } + sb.append("
\n"); - sb.append("\n"); + // 基本信息表格 + sb.append(buildInfoGrid(ci)); + + // 方法列表 + if (ci.methods != null) { + sb.append("
\n"); + sb.append("
📋 接口方法
\n"); + for (ApiDocModel.MethodInfo mi : ci.methods) { + sb.append(buildMethodCard(anchor, mi)); + } + sb.append("
\n"); + } + + sb.append("
\n"); + return sb.toString(); + } + + /** 构建类基本信息网格 */ + private String buildInfoGrid(ApiDocModel.ClassInfo ci) { + StringBuilder sb = new StringBuilder(500); + sb.append("
\n"); + sb.append("
全限定名") + .append(escapeHtml(ci.fullClassName)).append("
\n"); + if (ci.superClass != null) { + sb.append("
父类") + .append(escapeHtml(ci.superClass)).append("
\n"); + } + if (ci.interfaces != null && !ci.interfaces.isEmpty()) { + sb.append("
实现接口") + .append(escapeHtml(String.join(", ", ci.interfaces))).append("
\n"); + } + if (ci.annotations != null && !ci.annotations.isEmpty()) { + sb.append("
注解"); + for (String a : ci.annotations) { + sb.append("").append(escapeHtml(a)).append(""); + } + sb.append("
\n"); + } + if (ci.javadoc != null && !ci.javadoc.isEmpty()) { + sb.append("
描述") + .append(escapeHtml(ci.javadoc)).append("
\n"); + } + sb.append("
\n"); + return sb.toString(); + } + + /** 构建单个方法卡片 */ + private String buildMethodCard(String classAnchor, ApiDocModel.MethodInfo mi) { + String methodId = classAnchor + "_" + safeId(mi.name); + StringBuilder sb = new StringBuilder(2000); + sb.append("
\n"); + + // 方法头 + sb.append("
\n"); + sb.append(" ▼\n"); + String httpMethod = extractHttpMethod(mi.annotations); + if (!httpMethod.isEmpty()) { + sb.append(" ").append(httpMethod).append("\n"); + } + sb.append(" ").append(escapeHtml(mi.name)).append("\n"); + if (mi.returnType != null) { + sb.append(" → ").append(escapeHtml(mi.returnType)).append("\n"); + } + sb.append(" \n"); + sb.append(" \n"); + sb.append("
\n"); + + // 方法体 + sb.append("
\n"); + + // 注解 + if (mi.annotations != null && !mi.annotations.isEmpty()) { + sb.append("
\n"); + for (String a : mi.annotations) { + sb.append(" ").append(escapeHtml(a)).append("\n"); + } + sb.append("
\n"); + } + + // 描述 + if (mi.javadoc != null && !mi.javadoc.isEmpty()) { + sb.append("
").append(escapeHtml(mi.javadoc)).append("
\n"); + } + + // 签名(带复制按钮) + sb.append("
").append(escapeHtml(mi.fullSignature())) + .append("
\n"); + + // 方法参数表 + if (mi.params != null && !mi.params.isEmpty()) { + sb.append("
\n"); + sb.append("
方法参数
\n"); + sb.append(" \n"); + for (ApiDocModel.ParamInfo pi : mi.params) { + sb.append(" ") + .append("") + .append("\n"); + } + sb.append("
参数名类型全路径
").append(escapeHtml(pi.name)).append("").append(escapeHtml(pi.type)).append("").append(escapeHtml(pi.fullQualifiedName != null ? pi.fullQualifiedName : "")).append("
\n
\n"); + } + + // 请求 DTO + if (mi.requestFields != null && !mi.requestFields.isEmpty()) { + renderFieldsHtmlV2(sb, mi.requestFields, "请求参数", 0); + } + + // 返回包装器 + boolean wrapperHasFields = mi.responseWrapperFields != null && !mi.responseWrapperFields.isEmpty(); + if (mi.responseWrapperInheritedFrom != null) { + if (wrapperHasFields) { + renderFieldsHtmlV2(sb, mi.responseWrapperFields, + "返回结果 · 包装器 " + mi.responseWrapperInheritedFrom, 0); + } else { + sb.append("
\n"); + sb.append("
") + .append("⚠️ 返回结果 · 包装器 ").append(escapeHtml(mi.responseWrapperInheritedFrom)).append("") + .append(" 未配置 · 请在 api_base_classes.json 中补充") + .append("
\n"); + sb.append("
\n"); + } + } + + // 返回 DTO + if (mi.responseFields != null && !mi.responseFields.isEmpty()) { + String respTitle = mi.responseWrapperInheritedFrom != null + ? "返回结果 · " + mi.responseInnerType + : "返回结果"; + renderFieldsHtmlV2(sb, mi.responseFields, respTitle, 0); + } + + // throws + if (mi.throwsTypes != null && !mi.throwsTypes.isEmpty()) { + sb.append("
⚠ throws: ") + .append(escapeHtml(String.join(", ", mi.throwsTypes))) + .append("
\n"); + } + + sb.append("
\n"); // .method-body + sb.append("
\n"); // .method-card return sb.toString(); } @@ -378,11 +385,11 @@ public class ApiDocGenerator { } /** 判断类型是否为自定义类型(非 JDK 基础类型) */ - private static final java.util.Set BUILTIN_TYPES = new java.util.HashSet<>(java.util.Arrays.asList( + private static final Set BUILTIN_TYPES = new HashSet<>(Arrays.asList( "String", "Integer", "Long", "Short", "Byte", "Double", "Float", "Boolean", "Character", "int", "long", "short", "byte", "double", "float", "boolean", "char", "void", "BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime", - "Object", "Class", "Serializable", "String", "Void", + "Object", "Class", "Serializable", "Void", "List", "Set", "Map", "Collection", "ArrayList", "HashMap", "HashSet", "LinkedHashMap", "MultipartFile", "byte[]" )); @@ -415,661 +422,6 @@ public class ApiDocGenerator { return s.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_"); } - /** CSS 样式 */ - private void appendCss(StringBuilder sb) { - sb.append("\n"); - } - - /** JS 交互 */ - private void appendJs(StringBuilder sb) { - sb.append("\n"); - } - - /** 递归渲染字段表(Markdown)—— 继承分组 */ - private void renderFieldsMd(StringBuilder sb, List fields, String title, int depth) { - if (fields == null || fields.isEmpty()) return; - String icon = depth == 0 ? "📥 " : " └ "; - - // 按 inheritedFrom 分组 - List directFields = new ArrayList<>(); - Map> inheritedGroups = new LinkedHashMap<>(); - for (ApiDocModel.FieldInfo f : fields) { - if (f.inheritedFrom != null && !f.inheritedFrom.isEmpty()) { - inheritedGroups.computeIfAbsent(f.inheritedFrom, k -> new ArrayList<>()).add(f); - } else { - directFields.add(f); - } - } - - sb.append("**").append(icon).append(title).append("**(").append(fields.size()).append(" 字段)\n\n"); - - // 单表格:直接字段 + 子字段全部内联 - sb.append("| 参数名 | 参数类型 | 描述 |\n"); - sb.append("|--------|----------|------|\n"); - renderFieldRowsMd(sb, directFields, 0); - sb.append("\n"); - - // 继承分组 - for (Map.Entry> entry : inheritedGroups.entrySet()) { - sb.append("**↑ 继承自 `").append(entry.getKey()).append("`**\n\n"); - sb.append("| 参数名 | 参数类型 | 描述 |\n"); - sb.append("|--------|----------|------|\n"); - renderFieldRowsMd(sb, entry.getValue(), 0); - sb.append("\n"); - } - } - - /** 递归渲染 Markdown 字段行(父子内联在同一表格中) */ - private void renderFieldRowsMd(StringBuilder sb, List fields, int nesting) { - if (fields == null) return; - for (ApiDocModel.FieldInfo f : fields) { - String indent = nesting > 0 ? " ".repeat(nesting * 4) + "↳ " : ""; - String desc = f.javadoc != null - ? truncateFqnInJavadoc(f.javadoc).replace("\n", " ").replace("|", "\\|") - : "—"; - boolean hasChildren = f.children != null && !f.children.isEmpty(); - String typeDisplay = f.type; - if (hasChildren) { - typeDisplay = "📦 " + f.type; - } else if (isCustomType(f.type)) { - typeDisplay = "📋 " + f.type; - } - sb.append("| ").append(indent).append("`").append(f.name).append("` | `").append(typeDisplay).append("` | ") - .append(desc).append(" |\n"); - // 递归子字段 - if (f.children != null && !f.children.isEmpty()) { - renderFieldRowsMd(sb, f.children, nesting + 1); - } - } - } - - // ==================== Markdown 构建 ==================== - - private String buildMarkdown(ApiDocModel.ExportSelection selection, List docs) { - StringBuilder sb = new StringBuilder(); - sb.append("# 📄 API 接口文档\n\n"); - sb.append("> 生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("\n\n"); - - // 目录 - sb.append("## 目录\n\n"); - for (ApiDocModel.ProjectApiDoc doc : docs) { - sb.append("- **").append(doc.projectName).append("**(").append(doc.classes.size()) - .append(" 接口 / ").append(doc.totalMethods()).append(" 方法)\n"); - for (ApiDocModel.ClassInfo ci : doc.classes) { - sb.append(" - [").append(ci.className).append("](#") - .append(anchorId(doc.projectName, ci.fullClassName)).append(")(") - .append(ci.methods != null ? ci.methods.size() : 0).append(" 方法)\n"); - } - } - sb.append("\n---\n\n"); - - // 正文 - for (ApiDocModel.ProjectApiDoc doc : docs) { - sb.append("## 📦 ").append(doc.projectName).append("\n\n"); - sb.append("> ").append(doc.classes.size()).append(" 个接口 | ") - .append(doc.totalMethods()).append(" 个方法\n\n"); - - for (ApiDocModel.ClassInfo ci : doc.classes) { - String typeLabel = "interface".equals(ci.type) ? "🔌 接口" : "📦 类"; - String anchor = anchorId(doc.projectName, ci.fullClassName); - sb.append("###
").append(typeLabel).append(":").append(ci.className).append("\n\n"); - - // 基本信息 - sb.append("**基本信息**\n\n"); - sb.append("| 属性 | 值 |\n"); - sb.append("|------|----|\n"); - sb.append("| 全限定名 | `").append(ci.fullClassName).append("` |\n"); - if (ci.superClass != null) sb.append("| 父类 | `").append(ci.superClass).append("` |\n"); - if (ci.interfaces != null && !ci.interfaces.isEmpty()) - sb.append("| 实现接口 | `").append(String.join(", ", ci.interfaces)).append("` |\n"); - if (ci.annotations != null && !ci.annotations.isEmpty()) { - sb.append("| 注解 | "); - for (String a : ci.annotations) sb.append("`").append(a).append("` "); - sb.append("|\n"); - } - if (ci.javadoc != null && !ci.javadoc.isEmpty()) - sb.append("| 描述 | ").append(ci.javadoc.replace("\n", " ")).append(" |\n"); - sb.append("\n"); - - // 方法列表 - if (ci.methods != null && !ci.methods.isEmpty()) { - // 方法概览 - sb.append("**方法概览**(").append(ci.methods.size()).append(" 个)\n\n"); - sb.append("| HTTP | 方法 | 返回类型 |\n"); - sb.append("|------|------|----------|\n"); - for (ApiDocModel.MethodInfo mi : ci.methods) { - String http = extractHttpMethod(mi.annotations); - sb.append("| ").append(http.isEmpty() ? "—" : "`" + http + "`") - .append(" | `").append(mi.name).append("` | `").append(mi.returnType).append("` |\n"); - } - sb.append("\n"); - - // 方法详情 - for (ApiDocModel.MethodInfo mi : ci.methods) { - sb.append("#### `").append(mi.name).append("`\n\n"); - - if (mi.javadoc != null && !mi.javadoc.isEmpty()) { - sb.append("> ").append(mi.javadoc.replace("\n", "\n> ")).append("\n\n"); - } - - if (mi.annotations != null && !mi.annotations.isEmpty()) { - for (String a : mi.annotations) { - sb.append("`").append(a).append("` "); - } - sb.append("\n\n"); - } - - sb.append("```java\n").append(mi.fullSignature()).append("\n```\n\n"); - - // 方法参数 - if (mi.params != null && !mi.params.isEmpty()) { - sb.append("**方法参数**\n\n"); - sb.append("| 参数名 | 类型 | 全路径 |\n"); - sb.append("|--------|------|--------|\n"); - for (ApiDocModel.ParamInfo pi : mi.params) { - sb.append("| `").append(pi.name).append("` | `").append(pi.type).append("` | `") - .append(pi.fullQualifiedName != null ? pi.fullQualifiedName : "—").append("` |\n"); - } - sb.append("\n"); - } - - // 请求 DTO - if (mi.requestFields != null && !mi.requestFields.isEmpty()) { - renderFieldsMd(sb, mi.requestFields, "请求参数", 0); - } - - // 返回包装器(始终先展示) - boolean wrapperHasFieldsMd = mi.responseWrapperFields != null && !mi.responseWrapperFields.isEmpty(); - if (mi.responseWrapperInheritedFrom != null) { - if (wrapperHasFieldsMd) { - renderFieldsMd(sb, mi.responseWrapperFields, - "返回结果 · 包装器 " + mi.responseWrapperInheritedFrom, 0); - } else { - sb.append("\n> ⚠️ **返回结果 · 包装器 `").append(mi.responseWrapperInheritedFrom).append("`** — 未配置,请在 `api_base_classes.json` 中补充\n\n"); - } - } - - // 返回 DTO(泛型内层 T 的字段) - if (mi.responseFields != null && !mi.responseFields.isEmpty()) { - String respTitleMd = mi.responseWrapperInheritedFrom != null - ? "返回结果 · " + mi.responseInnerType - : "返回结果"; - renderFieldsMd(sb, mi.responseFields, respTitleMd, 0); - } - - // throws - if (mi.throwsTypes != null && !mi.throwsTypes.isEmpty()) { - sb.append("⚠ **throws:** `").append(String.join(", ", mi.throwsTypes)).append("`\n\n"); - } - - sb.append("---\n\n"); - } - } - - sb.append("\n"); - } - sb.append("\n---\n\n"); - } - - return sb.toString(); - } // ==================== 辅助 ==================== diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/DocRenderContext.java b/src/main/java/com/example/workutils/biz/apidoc/generator/DocRenderContext.java new file mode 100644 index 0000000..4a85748 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/DocRenderContext.java @@ -0,0 +1,60 @@ +package com.example.workutils.biz.apidoc.generator; + +/** + * API 文档渲染上下文,封装传入模板的所有数据。 + * + * AI 定位指引: + * - 新增模板变量 → 在本类添加字段,然后在 buildHtml() 中使用 + * - 修改文档标题/元信息 → 修改本类对应字段的赋值 + */ +public class DocRenderContext { + + /** 文档标题 */ + private String title; + + /** CSS 样式内容(从 api-doc.css 加载) */ + private String css; + + /** JS 脚本内容(从 api-doc.js 加载) */ + private String js; + + /** 文档主体 HTML */ + private String bodyHtml; + + /** 生成时间 */ + private String generationTime; + + /** 接口总数 */ + private int totalClasses; + + /** 方法总数 */ + private int totalMethods; + + // ===== 构造器 ===== + + public DocRenderContext() { + } + + // ===== Getter / Setter ===== + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public String getCss() { return css; } + public void setCss(String css) { this.css = css; } + + public String getJs() { return js; } + public void setJs(String js) { this.js = js; } + + public String getBodyHtml() { return bodyHtml; } + public void setBodyHtml(String bodyHtml) { this.bodyHtml = bodyHtml; } + + public String getGenerationTime() { return generationTime; } + public void setGenerationTime(String generationTime) { this.generationTime = generationTime; } + + public int getTotalClasses() { return totalClasses; } + public void setTotalClasses(int totalClasses) { this.totalClasses = totalClasses; } + + public int getTotalMethods() { return totalMethods; } + public void setTotalMethods(int totalMethods) { this.totalMethods = totalMethods; } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/HtmlResourceLoader.java b/src/main/java/com/example/workutils/biz/apidoc/generator/HtmlResourceLoader.java new file mode 100644 index 0000000..415a11c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/HtmlResourceLoader.java @@ -0,0 +1,69 @@ +package com.example.workutils.biz.apidoc.generator; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Scanner; + +/** + * API 文档资源加载器。 + * 从 classpath 加载分离的 CSS / JS / HTML 模板资源, + * 替换原先在 {@link ApiDocGenerator} 中硬编码字符串拼接的方式。 + * + * AI 定位指引: + * - CSS 样式修改 → src/main/resources/templates/apidoc/api-doc.css + * - JS 交互修改 → src/main/resources/templates/apidoc/api-doc.js + * - HTML 结构修改 → src/main/resources/templates/apidoc/api-doc.html + * - 新增资源类型 → 在本类添加对应加载方法 + */ +public class HtmlResourceLoader { + + private static final String RESOURCE_BASE = "templates/apidoc/"; + + /** + * 加载 HTML 模板。 + * 对应资源文件:resources/templates/apidoc/api-doc.html。 + * 模板中使用 {{PLACEHOLDER}} 格式标记动态替换位。 + */ + public String loadTemplate() { + return loadResource(RESOURCE_BASE + "api-doc.html"); + } + + /** + * 加载 CSS 样式。 + * 对应资源文件:resources/templates/apidoc/api-doc.css + */ + public String loadCss() { + return loadResource(RESOURCE_BASE + "api-doc.css"); + } + + /** + * 加载 JS 脚本。 + * 对应资源文件:resources/templates/apidoc/api-doc.js + */ + public String loadJs() { + return loadResource(RESOURCE_BASE + "api-doc.js"); + } + + /** + * 从 classpath 读取资源文件内容。 + * 所有资源必须以 UTF-8 编码。 + */ + private String loadResource(String path) { + ClassLoader cl = getClass().getClassLoader(); + if (cl == null) { + cl = ClassLoader.getSystemClassLoader(); + } + try (InputStream is = cl.getResourceAsStream(path)) { + if (is == null) { + throw new IOException("资源文件未找到: " + path); + } + try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) { + scanner.useDelimiter("\\A"); + return scanner.hasNext() ? scanner.next() : ""; + } + } catch (IOException e) { + throw new RuntimeException("加载资源失败: " + path, e); + } + } +} diff --git a/src/main/resources/templates/apidoc/api-doc.css b/src/main/resources/templates/apidoc/api-doc.css new file mode 100644 index 0000000..4dacd2e --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.css @@ -0,0 +1,188 @@ +/* ============================================================ + * API 接口文档样式 + * 从 ApiDocGenerator.appendCss() 提取,便于独立维护 + * ============================================================ */ + +/* ===== 基础重置 ===== */ +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:'Microsoft YaHei',-apple-system,sans-serif;color:#1d1d1f;background:#f2f3f5;display:flex;flex-direction:column;min-height:100vh} + +/* ===== 顶部栏 ===== */ +.topbar{display:flex;align-items:center;height:48px;background:#fff;border-bottom:1px solid #e5e6eb;padding:0 20px;position:sticky;top:0;z-index:100} +.topbar-brand{font-weight:700;font-size:16px;color:#1d1d1f;margin-right:32px} +.topbar-tabs{display:flex;gap:0} +.tab{padding:12px 20px;font-size:14px;color:#86909c;cursor:pointer;border-bottom:2px solid transparent;transition:.2s} +.tab.active{color:#1677ff;border-bottom-color:#1677ff;font-weight:600} +.topbar-meta{margin-left:auto;font-size:12px;color:#86909c;white-space:nowrap} + +/* ===== 搜索框 ===== */ +.search-input{padding:5px 12px;border:1px solid #e5e6eb;border-radius:4px;font-size:13px;outline:none;width:200px;transition:border .2s,box-shadow .2s;font-family:inherit} +.search-input:focus{border-color:#1677ff;box-shadow:0 0 0 2px rgba(22,119,255,.1)} + +/* ===== 统计摘要栏 ===== */ +.stats-bar{display:flex;align-items:center;gap:20px;padding:6px 20px;background:#fff;border-bottom:1px solid #e5e6eb;font-size:12px;color:#86909c} +.stats-item{display:flex;align-items:center;gap:3px} +.stats-num{font-weight:700;font-size:14px;color:#1677ff} +.stats-actions{margin-left:auto;display:flex;gap:6px} +.ctrl-btn{padding:3px 10px;border:1px solid #e5e6eb;border-radius:3px;background:#fff;color:#4e5969;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit} +.ctrl-btn:hover{border-color:#1677ff;color:#1677ff;background:#e8f3ff} + +/* ===== 布局 ===== */ +.main-layout{display:flex;flex:1} + +/* ===== 侧栏导航 ===== */ +.sidebar{width:280px;min-width:280px;background:#fff;border-right:1px solid #e5e6eb;overflow-y:auto;padding:16px 0;position:sticky;top:78px;height:calc(100vh - 78px)} +.sidebar-title{font-size:14px;font-weight:700;color:#1d1d1f;padding:0 16px 12px;border-bottom:1px solid #f2f3f5;margin-bottom:8px} +.nav-group{margin-bottom:4px} +.nav-group-title{padding:8px 16px;font-size:13px;font-weight:600;color:#4e5969;cursor:pointer;display:flex;align-items:center;gap:6px;user-select:none;transition:.15s} +.nav-group-title:hover{background:#f2f3f5} +.nav-group-title .arrow{font-size:10px;transition:transform .2s;display:inline-block} +.nav-group-title.open .arrow{transform:rotate(90deg)} +.nav-group-title small{font-weight:400;color:#86909c;font-size:11px} +.nav-group-items{overflow:hidden;max-height:0;transition:max-height .3s ease} +.nav-group-title.open+.nav-group-items{max-height:2000px} +.nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px 6px 32px;font-size:12px;color:#4e5969;text-decoration:none;transition:.15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.nav-item:hover,.nav-item.active{color:#1677ff;background:#e8f3ff} +.nav-item-icon{font-size:14px;flex-shrink:0} +.nav-method{padding-left:48px;font-size:11px;gap:6px} +.nav-method-dot{font-size:6px;color:#c9cdd4;flex-shrink:0;line-height:1} +.nav-http-tag{display:inline-block;min-width:28px;text-align:center;font-size:10px;font-weight:600;padding:0 4px;border-radius:2px;line-height:16px;flex-shrink:0} +.nav-http-tag.get{background:#e8f3ff;color:#1677ff} +.nav-http-tag.post{background:#e8ffea;color:#00b42a} +.nav-http-tag.put{background:#fff7e6;color:#ff7d00} +.nav-http-tag.delete{background:#ffece8;color:#f53f3f} +.nav-http-tag.req{background:#f2f3f5;color:#86909c} +.nav-http-tag.rpc{background:#f3e8ff;color:#722ed1} + +/* ===== 内容区 ===== */ +.content{flex:1;padding:24px;overflow-y:auto;max-width:calc(100vw - 280px)} +.project-section{margin-bottom:32px} +.project-title{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid #e5e6eb} + +/* ===== 接口卡片 ===== */ +.api-card{background:#fff;border-radius:8px;margin-bottom:16px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06)} +.api-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #f2f3f5} +.api-header-left{display:flex;align-items:center;gap:10px} +.api-name{font-size:16px;font-weight:700;color:#1d1d1f} +.api-header-right{font-size:13px;color:#86909c} +.type-badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:600} +.type-badge.interface{background:#e8f3ff;color:#1677ff} +.type-badge.class{background:#e8ffea;color:#00b42a} + +/* ===== 信息网格 ===== */ +.info-grid{padding:16px 20px;display:grid;grid-template-columns:1fr 1fr;gap:10px} +.info-item{display:flex;align-items:baseline;gap:8px;font-size:13px} +.info-label{color:#86909c;white-space:nowrap;min-width:64px} +.info-value{color:#1d1d1f;word-break:break-all} +.info-value.mono{font-family:'Consolas','Courier New',monospace;font-size:12px;color:#4e5969} + +/* ===== 注解标签 ===== */ +.anno-tag{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;margin:1px 3px;background:#fff7e6;color:#d46b08;border:1px solid #ffd591} +.method-anno{background:#f6ffed;color:#389e0d;border-color:#b7eb8f} + +/* ===== 方法列表 ===== */ +.methods-section{padding:0 20px 16px} +.section-label{font-size:14px;font-weight:700;color:#1d1d1f;padding:8px 0;border-top:1px solid #f2f3f5} + +/* ===== 方法卡片 ===== */ +.method-card{border:1px solid #f2f3f5;border-radius:8px;margin:10px 0;overflow:hidden} +.method-header{display:flex;align-items:center;gap:10px;padding:12px 16px;background:#fafbfc;cursor:pointer;user-select:none;transition:background .15s} +.method-header:hover{background:#f2f3f5} +.method-arrow{font-size:10px;transition:transform .2s;color:#86909c} +.method-header.collapsed .method-arrow{transform:rotate(-90deg)} +.method-name{font-size:14px;font-weight:700;color:#1677ff;font-family:'Consolas','Courier New',monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.method-return{font-size:12px;color:#86909c;flex-shrink:0} +.method-return code{color:#00b42a;background:#e8ffea;padding:2px 6px;border-radius:3px;font-size:11px} +.http-badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:700;color:#fff} +.http-badge.get{background:#1677ff} +.http-badge.post{background:#00b42a} +.http-badge.put{background:#ff7d00} +.http-badge.delete{background:#f53f3f} +.http-badge.req{background:#86909c} +.http-badge.rpc{background:#722ed1} + +/* ===== 方法体 ===== */ +.method-body{padding:12px 16px} +.method-header.collapsed+.method-body{display:none} +.method-annotations{margin-bottom:8px} +.method-desc{font-size:13px;color:#4e5969;padding:8px 12px;background:#fafbfc;border-radius:4px;margin-bottom:10px;border-left:3px solid #e5e6eb;white-space:pre-wrap} +.method-signature{padding:10px 14px;background:#1e1e1e;border-radius:6px;margin-bottom:12px;overflow-x:auto;position:relative;display:flex;align-items:center} +.method-signature code{font-family:'Consolas','Courier New',monospace;font-size:13px;color:#d4d4d4;flex:1} +.copy-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);padding:3px 10px;border:1px solid #555;border-radius:3px;background:transparent;color:#999;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit} +.copy-btn:hover{color:#fff;border-color:#888} +.copy-btn.copied{color:#52c41a;border-color:#52c41a} + +/* ===== 复制按钮 ===== */ +.api-copy-btn{padding:4px 12px;border:1px solid #1677ff;border-radius:4px;background:#e8f3ff;color:#1677ff;font-size:12px;cursor:pointer;transition:.15s;font-family:inherit} +.api-copy-btn:hover{background:#1677ff;color:#fff} +.api-copy-btn.copied{background:#52c41a;border-color:#52c41a;color:#fff} +.method-copy-btn{padding:3px 10px;border:1px solid #e5e6eb;border-radius:3px;background:#fff;color:#4e5969;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit;flex-shrink:0} +.method-copy-btn:hover{border-color:#1677ff;color:#1677ff;background:#e8f3ff} +.method-copy-btn.copied{border-color:#52c41a;color:#52c41a;background:#f6ffed} +.api-copy-btn.txt{background:#fff;color:#1677ff} +.api-copy-btn.txt:hover{background:#e8f3ff} +.method-copy-btn.txt{color:#1677ff;border-color:#91caff;background:#f0f7ff} +.method-copy-btn.img{background:#1677ff;color:#fff;border-color:#1677ff} +.method-copy-btn.img:hover{background:#0958d9;border-color:#0958d9} + +/* ===== 参数块 ===== */ +.params-block{margin-bottom:12px} +.params-block-title{font-size:13px;font-weight:600;color:#4e5969;margin-bottom:6px} + +/* ===== DTO 块(可折叠卡片) ===== */ +.dto-block{margin-bottom:10px;border:1px solid #e5e6eb;border-radius:6px;overflow:hidden} +.dto-block-title{padding:8px 12px;background:#fafbfc;font-size:13px;font-weight:600;cursor:pointer;border-left:3px solid #1677ff;user-select:none;display:flex;align-items:center;gap:6px;transition:background .15s} +.dto-block-title:hover{background:#f2f3f5} +.dto-block-title small{font-weight:400;color:#86909c} +.dto-arrow{font-size:10px;transition:transform .2s;color:#86909c} +.dto-block-title.collapsed .dto-arrow{transform:rotate(-90deg)} +.dto-block-body{padding:0} +.dto-block-title.collapsed+.dto-block-body{display:none} + +/* ===== 未配置包装器警告 ===== */ +.dto-block-warn{border-color:#ff7d00;background:#fff7e6} +.dto-title-warn{border-left-color:#ff7d00!important;background:#fff7e6!important;color:#d46b08!important} +.warn-hint{color:#ff7d00!important;font-weight:600;font-size:11px;background:#fff0d6;padding:2px 8px;border-radius:3px;margin-left:6px} + +/* ===== 表格 ===== */ +.data-table{width:100%;border-collapse:collapse;font-size:12px} +.data-table th{background:#fafbfc;text-align:left;padding:7px 10px;border:1px solid #e5e6eb;font-weight:600;color:#4e5969;white-space:nowrap} +.data-table td{padding:7px 10px;border:1px solid #e5e6eb;color:#1d1d1f;vertical-align:top} +.data-table td:first-child{min-width:200px} +.data-table tr:hover td{background:#f7f8fa} +.data-table code{font-family:'Consolas','Courier New',monospace;font-size:11px;background:#f2f3f5;padding:1px 5px;border-radius:2px;color:#1677ff} +.data-table code.field-name{color:#d46b08} + +/* ===== 字段行层级样式 ===== */ +.field-indent{display:inline-block;min-width:0} +.field-expand-toggle{display:inline-block;font-size:10px;color:#86909c;cursor:pointer;width:12px;text-align:center;transition:transform .15s;user-select:none} +.field-expand-toggle.open{transform:rotate(90deg)} +.field-row.d0 td{background:#fff} +.field-row.d1 td{background:#f9fbff} +.field-row.d2 td{background:#f6f9ff} +.field-row.d3 td{background:#f3f6ff} +.field-row.d1 td:first-child{border-left:3px solid #52c41a} +.field-row.d2 td:first-child{border-left:3px solid #fa8c16} +.field-row.d3 td:first-child{border-left:3px solid #722ed1} +.type-complex{color:#722ed1!important;background:#f3e8ff!important} +.type-custom{color:#0958d9!important;background:#e6f4ff!important;border:1px solid #91caff} + +/* ===== 全限定名与继承 ===== */ +.fqn{font-size:11px;color:#86909c;font-family:'Consolas','Courier New',monospace} +.inherit-tag{font-size:11px;color:#86909c;font-style:italic;margin-left:4px} +.inherit-section{border-top:1px dashed #e5e6eb;padding-top:8px;margin-top:8px} +.inherit-header{display:inline-block;font-size:11px;font-weight:600;color:#86909c;padding:2px 8px;background:#f7f8fa;border-radius:3px;margin-bottom:6px} +.inherit-header code{font-size:11px;color:#1677ff;background:#e8f3ff;padding:1px 5px;border-radius:2px} + +/* ===== throws 异常 ===== */ +.throws-block{font-size:12px;color:#f53f3f;padding:6px 0} +.throws-block code{background:#fff2f0;padding:2px 6px;border-radius:2px} + +/* ===== 搜索高亮 ===== */ +.api-card.search-hidden,.nav-item.search-hidden,.nav-group.search-hidden{display:none!important} +.method-card.search-hidden{display:none!important} +.search-highlight{background:#fff3cd;padding:1px 2px;border-radius:2px} +.search-match{box-shadow:0 0 0 2px rgba(22,119,255,.25)} + +/* ===== 响应式 ===== */ +@media(max-width:900px){.sidebar{display:none}.content{max-width:100vw}} diff --git a/src/main/resources/templates/apidoc/api-doc.html b/src/main/resources/templates/apidoc/api-doc.html new file mode 100644 index 0000000..f06baa6 --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.html @@ -0,0 +1,43 @@ + + + + + +API 接口文档 + + + + + +
+
📄 API 接口文档
+
+ 接口文档 +
+ +
生成时间: {{GENERATE_TIME}}
+
+ +{{STATS_BAR}} + +
+ + +
+{{CONTENT}} +
+
+ + + + + diff --git a/src/main/resources/templates/apidoc/api-doc.js b/src/main/resources/templates/apidoc/api-doc.js new file mode 100644 index 0000000..79d1ce1 --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.js @@ -0,0 +1,272 @@ +/* ============================================================ + * API 接口文档交互脚本 + * 从 ApiDocGenerator.appendJs() 提取,版块化便于独立维护 + * + * AI 定位指引: + * - 搜索过滤 → 搜索 "filterDocs" + * - 展开/折叠 → 搜索 "expandAll" / "collapseAll" + * - 复制功能 → 搜索 "copyToClip" / "copyImage" / "copyRtfFromEl" + * - DTO/字段展开 → 搜索 "toggleDto" / "toggleChildren" + * ============================================================ */ + +/* ===== 导航分组折叠 ===== */ +document.querySelectorAll('.nav-group-title').forEach(function(el) { + el.classList.add('open'); +}); +function toggleGroup(el) { + el.classList.toggle('open'); + document.querySelectorAll('.nav-group-title.open + .nav-group-items').forEach(function(i) { + i.style.maxHeight = i.scrollHeight + 'px'; + }); +} +document.querySelectorAll('.nav-group-title.open + .nav-group-items').forEach(function(i) { + i.style.maxHeight = i.scrollHeight + 'px'; +}); + +/* ===== 方法卡片折叠 ===== */ +function toggleMethod(el) { + el.classList.toggle('collapsed'); +} +function toggleDto(el) { + el.classList.toggle('collapsed'); +} + +/* ===== 字段子行折叠 ===== */ +function toggleChildren(el) { + el.classList.toggle('open'); + var row = el.closest('tr'); + if (!row) return; + var d = parseInt(row.dataset.depth) || 0; + var hide = el.classList.contains('open'); + var next = row.nextElementSibling; + while (next && next.classList.contains('field-row')) { + var nd = parseInt(next.dataset.depth) || 0; + if (nd <= d) break; + next.style.display = hide ? '' : 'none'; + next = next.nextElementSibling; + } +} + +/* ===== 展开/折叠全部 ===== */ +function expandAll() { + document.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + document.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); +} +function collapseAll() { + document.querySelectorAll('.method-header').forEach(function(h) { h.classList.add('collapsed'); }); + document.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.add('collapsed'); }); +} + +/* ===== 导航高亮(IntersectionObserver) ===== */ +var observer = new IntersectionObserver(function(entries) { + entries.forEach(function(e) { + if (e.isIntersecting) { + document.querySelectorAll('.nav-item.active').forEach(function(a) { a.classList.remove('active'); }); + var n = document.querySelector('.nav-item[href="#' + e.target.id + '"]'); + if (n) n.classList.add('active'); + } + }); +}); +document.querySelectorAll('.api-card[id]').forEach(function(c) { observer.observe(c); }); + +/* ===== 侧栏导航点击展开对应方法 ===== */ +document.querySelectorAll('.nav-method').forEach(function(link) { + link.addEventListener('click', function(e) { + var targetId = this.getAttribute('href'); + if (!targetId || !targetId.startsWith('#')) return; + var el = document.getElementById(targetId.substring(1)); + if (!el) return; + var methodCard = el.closest('.method-card'); + if (!methodCard) return; + var header = methodCard.querySelector('.method-header'); + if (header) header.classList.remove('collapsed'); + var body = methodCard.querySelector('.method-body'); + if (body) body.style.display = ''; + var apiCard = methodCard.closest('.api-card'); + if (apiCard) { apiCard.classList.remove('collapsed'); var apiBody = apiCard.querySelector('.api-body'); if (apiBody) apiBody.style.display = ''; } + }); +}); + +/* ===== 复制到剪贴板(纯文本) ===== */ +function copyToClip(text, btn) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + markCopied(btn, '✔ 已复制'); + }).catch(function() { + fallbackCopy(text, btn); + }); + } else { + fallbackCopy(text, btn); + } +} +function fallbackCopy(text, btn) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); markCopied(btn, '✔ 已复制'); } catch (e) { markCopied(btn, '⚠ 失败'); } + document.body.removeChild(ta); +} +function markCopied(btn, msg) { + var orig = btn.textContent; + btn.textContent = msg; + btn.classList.add('copied'); + setTimeout(function() { btn.textContent = orig; btn.classList.remove('copied'); }, 1500); +} + +/* ===== 复制代码签名 ===== */ +function copyCode(btn) { + var pre = btn.closest('.method-signature'); + if (!pre) { var card = btn.closest('.method-card'); if (card) pre = card.querySelector('.method-signature'); } + var codeEl = pre ? pre.querySelector('code') : null; + var text = codeEl ? codeEl.textContent : (pre ? pre.textContent.replace('复制', '') : ''); + copyToClip(text.trim(), btn); +} + +/* ===== 复制为图片(需要 html2canvas) ===== */ +function copyImage(el, btn) { + if (!el || typeof html2canvas === 'undefined') { markCopied(btn, '⚠ 不可用'); return; } + var clone = el.cloneNode(true); + clone.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + clone.querySelectorAll('.method-body').forEach(function(b) { b.style.display = ''; }); + clone.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); + clone.style.position = 'fixed'; + clone.style.left = '-9999px'; + clone.style.top = '0'; + clone.style.zIndex = '-1'; + document.body.appendChild(clone); + html2canvas(clone, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', windowWidth: clone.scrollWidth, windowHeight: clone.scrollHeight }).then(function(canvas) { + canvas.toBlob(function(blob) { + if (blob && navigator.clipboard && navigator.clipboard.write) { + var item = new ClipboardItem({ 'image/png': blob }); + navigator.clipboard.write([item]).then(function() { + markCopied(btn, '✔ 图片已复制'); + }).catch(function() { + markCopied(btn, '⚠ 请粘贴'); + }); + } else { markCopied(btn, '⚠ 不支持'); } + }, 'image/png'); + }).catch(function() { + markCopied(btn, '⚠ html2canvas 失败'); + }).finally(function() { + if (clone.parentNode) document.body.removeChild(clone); + }); +} + +/* ===== 复制为 Word 富文本 (RTF) ===== */ +function copyRtfFromEl(el, btn) { + if (!el) { markCopied(btn, '⚠ 无内容'); return; } + var wrap = document.createElement('div'); + wrap.innerHTML = el.innerHTML; + // 清除按钮 + wrap.querySelectorAll('button').forEach(function(b) { b.remove(); }); + wrap.querySelectorAll('.copy-btn,.api-copy-btn,.method-copy-btn').forEach(function(b) { b.remove(); }); + wrap.querySelectorAll('.field-expand-toggle').forEach(function(b) { b.remove(); }); + // 展开所有折叠区域 + wrap.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + wrap.querySelectorAll('.method-body').forEach(function(b) { b.style.display = ''; }); + wrap.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); + // 清除 script/style + wrap.querySelectorAll('script,style').forEach(function(s) { s.remove(); }); + var html = '' + wrap.innerHTML + ''; + if (navigator.clipboard && navigator.clipboard.write) { + var blob = new Blob([html], { type: 'text/html' }); + navigator.clipboard.write([new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([wrap.textContent || ''], { type: 'text/plain' }) })]) + .then(function() { markCopied(btn, '✔ 富文本已复制'); }) + .catch(function() { legacyRich(html, btn); markCopied(btn, '⚠ 已复制富文本'); }); + } else { + legacyRich(html, btn); + markCopied(btn, '⚠ 已复制富文本'); + } +} + +/* ===== 获取文档样式 ===== */ +function getDocStyles() { + var css = ''; + document.querySelectorAll('style').forEach(function(s) { css += s.textContent; }); + return css; +} + +/* ===== 旧版富文本复制兼容 ===== */ +function legacyRich(html, btn) { + var listener = function(e) { + e.clipboardData.setData('text/html', html); + e.clipboardData.setData('text/plain', ''); + e.preventDefault(); + document.removeEventListener('copy', listener); + }; + document.addEventListener('copy', listener); + document.execCommand('copy'); + document.removeEventListener('copy', listener); +} + +/* ===== 搜索过滤 ===== */ +function filterDocs() { + var kw = (document.getElementById('searchInput') || {}).value; + if (!kw || !kw.trim()) { resetSearch(); return; } + kw = kw.trim().toLowerCase(); + var visible = 0; + // 导航过滤 + document.querySelectorAll('.nav-group').forEach(function(g) { + var hasVisible = false; + g.querySelectorAll('.nav-item').forEach(function(item) { + var txt = (item.textContent || '').toLowerCase(); + if (txt.indexOf(kw) >= 0) { item.classList.remove('search-hidden'); hasVisible = true; visible++; } + else { item.classList.add('search-hidden'); } + }); + if (hasVisible) { + g.classList.remove('search-hidden'); + var t = g.querySelector('.nav-group-title'); + if (t) t.classList.add('open'); + var items = g.querySelector('.nav-group-items'); + if (items) items.style.maxHeight = items.scrollHeight + 'px'; + } else { g.classList.add('search-hidden'); } + }); + // 内容过滤 + document.querySelectorAll('.api-card').forEach(function(card) { + var txt = (card.textContent || '').toLowerCase(); + var match = txt.indexOf(kw) >= 0; + if (!match) { + card.querySelectorAll('.method-card').forEach(function(mc) { + var mt = (mc.textContent || '').toLowerCase(); + if (mt.indexOf(kw) >= 0) { match = true; mc.classList.remove('search-hidden'); mc.querySelector('.method-header').classList.remove('collapsed'); mc.querySelector('.method-body').style.display = ''; } + else { mc.classList.add('search-hidden'); } + }); + } + if (match) { card.classList.remove('search-hidden'); var body = card.querySelector('.api-body'); if (body) { body.style.display = ''; card.classList.remove('collapsed'); } } + else { card.classList.add('search-hidden'); } + }); + var searchCount = document.getElementById('searchCount'); + if (searchCount) searchCount.textContent = visible > 0 ? '匹配 ' + visible + ' 项' : '无匹配'; +} + +function resetSearch() { + document.querySelectorAll('.search-hidden').forEach(function(el) { el.classList.remove('search-hidden'); }); + document.querySelectorAll('.search-highlight').forEach(function(el) { var p = el.parentNode; if (p) p.replaceChild(document.createTextNode(el.textContent), el); }); + var searchCount = document.getElementById('searchCount'); + if (searchCount) searchCount.textContent = ''; +} + +/* ===== ESC 清空搜索 ===== */ +(function() { + var si = document.getElementById('searchInput'); + if (si) { si.addEventListener('keydown', function(e) { if (e.key === 'Escape') { this.value = ''; resetSearch(); this.blur(); } }); } +})(); + +/* ===== 手动选择区域复制富文本兼容 ===== */ +document.querySelectorAll('.api-card .api-body, .method-body').forEach(function(el) { + el.addEventListener('copy', function(e) { + var sel = window.getSelection(); + if (!sel.rangeCount) return; + var range = sel.getRangeAt(0); + var wrapper = document.createElement('div'); + wrapper.appendChild(range.cloneContents()); + wrapper.querySelectorAll('button,.copy-btn,.api-copy-btn,.method-copy-btn').forEach(function(b) { b.remove(); }); + var html = wrapper.innerHTML; + var css = getDocStyles(); + e.clipboardData.setData('text/html', '' + html + ''); + e.preventDefault(); + }); +});