result = new ArrayList<>();
+ int i = m, j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && a.get(i - 1).equals(b.get(j - 1))) {
+ result.add(new DiffLine(DiffType.UNCHANGED, a.get(i - 1)));
+ i--; j--;
+ } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ result.add(new DiffLine(DiffType.ADDED, b.get(j - 1)));
+ j--;
+ } else {
+ result.add(new DiffLine(DiffType.REMOVED, a.get(i - 1)));
+ i--;
+ }
+ }
+ Collections.reverse(result);
+ return new DiffResult(result);
+ }
+
+}
diff --git a/src/main/java/com/example/workutils/biz/diff/DiffWindow.java b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java
new file mode 100644
index 0000000..6805b3d
--- /dev/null
+++ b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java
@@ -0,0 +1,857 @@
+package com.example.workutils.biz.diff;
+
+import javafx.application.Platform;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.Scene;
+import javafx.scene.control.*;
+import javafx.scene.input.Clipboard;
+import javafx.scene.input.ClipboardContent;
+import javafx.scene.input.KeyCode;
+import javafx.scene.input.KeyCodeCombination;
+import javafx.scene.input.KeyCombination;
+import javafx.scene.layout.*;
+import javafx.scene.paint.Color;
+import javafx.scene.text.Font;
+import javafx.scene.web.WebEngine;
+import javafx.scene.web.WebView;
+import javafx.stage.DirectoryChooser;
+import javafx.stage.Stage;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.concurrent.CountDownLatch;
+
+/**
+ * JavaFX 文本对比可视化窗口
+ *
+ * 上半区:左右 TextArea 粘贴原文/对比文;
+ * 下半区:WebView 渲染字符级差异,行内按字符着色(浅红=删除,浅绿=新增,无色=未变)。
+ *
+ * 快捷键:Ctrl+Enter 对比,Ctrl+L 交换左右。
+ */
+class DiffWindow {
+
+ private static final Font MONO_FONT = Font.font("Consolas", 13);
+ private static final Font TITLE_FONT = Font.font("System", 13);
+ private static final String BORDER_STYLE = "-fx-border-color: #e0e0e0; -fx-border-width: 1; -fx-border-radius: 3;";
+ private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss");
+
+ /** 字符级对比相似度阈值:相似度 >= 此值视为"修改同一行" */
+ private static final double CHAR_SIMILARITY_THRESHOLD = 0.3;
+
+ private final DiffPlugin plugin;
+ private final Stage stage;
+ private TextArea leftArea, rightArea;
+ private WebView webView;
+ private WebEngine webEngine;
+ private Label statusLabel;
+ final CountDownLatch closeLatch;
+
+ /** 最近一次对比的纯文本结果,供复制/保存使用 */
+ private String lastPlainResult = "";
+ /** 差异行的 HTML 锚点 ID 列表,用于跳转导航 */
+ private final List diffAnchorIds = new ArrayList<>();
+ /** 当前跳转到的差异索引(0-based) */
+ private int currentDiffIdx = -1;
+
+ DiffWindow(DiffPlugin plugin) {
+ this(plugin, new CountDownLatch(1));
+ }
+
+ DiffWindow(DiffPlugin plugin, CountDownLatch closeLatch) {
+ this.plugin = plugin;
+ this.closeLatch = closeLatch;
+ this.stage = new Stage();
+ buildUI();
+ }
+
+ // ==================== 窗口入口 ====================
+
+ void show() {
+ stage.show();
+ leftArea.requestFocus();
+ }
+
+ void waitForClose() {
+ try {
+ closeLatch.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ // ==================== UI 构建 ====================
+
+ private void buildUI() {
+ stage.setTitle("文本对比工具");
+ stage.setOnHidden(e -> closeLatch.countDown());
+
+ // ---- 上半区:左右输入 ----
+ SplitPane inputSplit = new SplitPane();
+ inputSplit.getItems().addAll(buildInputSide("原文", true),
+ buildInputSide("对比文", false));
+ inputSplit.setDividerPositions(0.5);
+
+ // ---- 操作栏 ----
+ Button compareBtn = accentButton("▶ 开始对比");
+ Button swapBtn = normalButton("⇄ 交换左右");
+ Button clearAllBtn = normalButton("清空全部");
+ compareBtn.setOnAction(e -> doCompare());
+ swapBtn.setOnAction(e -> swapText());
+ clearAllBtn.setOnAction(e -> clearAll());
+
+ HBox actionBar = new HBox(10, clearAllBtn, compareBtn, swapBtn);
+ actionBar.setAlignment(Pos.CENTER);
+ actionBar.setPadding(new Insets(6, 0, 6, 0));
+
+ VBox topSection = new VBox(5, inputSplit, actionBar);
+ VBox.setVgrow(inputSplit, Priority.ALWAYS);
+
+ // ---- 下半区:WebView 结果 ----
+ VBox resultSection = buildResultSection();
+
+ SplitPane mainSplit = new SplitPane();
+ mainSplit.setOrientation(javafx.geometry.Orientation.VERTICAL);
+ mainSplit.getItems().addAll(topSection, resultSection);
+ mainSplit.setDividerPositions(0.38);
+
+ // ---- 状态栏 ----
+ statusLabel = new Label("Ready — 粘贴文本到上方区域,Ctrl+Enter 开始对比");
+ statusLabel.setFont(Font.font("System", 12));
+ statusLabel.setTextFill(Color.rgb(117, 117, 117));
+ statusLabel.setPadding(new Insets(4, 8, 4, 8));
+ statusLabel.setMaxWidth(Double.MAX_VALUE);
+
+ BorderPane root = new BorderPane();
+ root.setCenter(mainSplit);
+ root.setBottom(statusLabel);
+ root.setPadding(new Insets(10));
+
+ Scene scene = new Scene(root, 1200, 800);
+ scene.getAccelerators().put(
+ new KeyCodeCombination(KeyCode.ENTER, KeyCombination.CONTROL_DOWN),
+ this::doCompare);
+ scene.getAccelerators().put(
+ new KeyCodeCombination(KeyCode.L, KeyCombination.CONTROL_DOWN),
+ this::swapText);
+
+ stage.setScene(scene);
+ stage.setMinWidth(800);
+ stage.setMinHeight(500);
+ }
+
+ private VBox buildInputSide(String title, boolean isLeft) {
+ Label titleLabel = new Label(title);
+ titleLabel.setFont(TITLE_FONT);
+ titleLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: #424242;");
+ titleLabel.setPadding(new Insets(0, 0, 0, 4));
+
+ Button pasteBtn = tinyButton("粘贴");
+ Button clearBtn = tinyButton("清空");
+ HBox btnGroup = new HBox(4, pasteBtn, clearBtn);
+ btnGroup.setAlignment(Pos.CENTER_RIGHT);
+
+ BorderPane header = new BorderPane();
+ header.setLeft(titleLabel);
+ header.setRight(btnGroup);
+ header.setPadding(new Insets(0, 0, 3, 0));
+
+ TextArea area = new TextArea();
+ area.setFont(MONO_FONT);
+ area.setStyle(BORDER_STYLE + "-fx-background-insets: 0; -fx-background-radius: 3;");
+ area.setWrapText(false);
+
+ pasteBtn.setOnAction(e -> pasteFromClipboard(area));
+ clearBtn.setOnAction(e -> {
+ area.clear();
+ refreshLineCount();
+ });
+ area.textProperty().addListener((obs, old, val) -> refreshLineCount());
+
+ VBox panel = new VBox(3, header, area);
+ VBox.setVgrow(area, Priority.ALWAYS);
+
+ if (isLeft) leftArea = area; else rightArea = area;
+ return panel;
+ }
+
+ private VBox buildResultSection() {
+ Label titleLabel = new Label("对比结果");
+ titleLabel.setFont(TITLE_FONT);
+ titleLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: #424242;");
+ titleLabel.setPadding(new Insets(0, 0, 0, 4));
+
+ Button prevBtn = tinyButton("▲上一处差异");
+ Button nextBtn = tinyButton("▼下一处差异");
+ Button copyBtn = tinyButton("复制结果");
+ Button saveBtn = tinyButton("保存报告");
+ Label diffCounter = new Label("");
+ diffCounter.setFont(Font.font("System", 11));
+ diffCounter.setTextFill(Color.rgb(117, 117, 117));
+
+ prevBtn.setOnAction(e -> jumpToPrevDiff());
+ nextBtn.setOnAction(e -> jumpToNextDiff());
+ copyBtn.setOnAction(e -> copyResult());
+ saveBtn.setOnAction(e -> saveReport());
+
+ HBox headerRight = new HBox(5, prevBtn, nextBtn, copyBtn, saveBtn);
+ headerRight.setAlignment(Pos.CENTER_RIGHT);
+
+ BorderPane header = new BorderPane();
+ header.setLeft(titleLabel);
+ header.setCenter(diffCounter);
+ header.setRight(headerRight);
+ header.setPadding(new Insets(0, 0, 3, 0));
+
+ webView = new WebView();
+ webEngine = webView.getEngine();
+ webView.setStyle(BORDER_STYLE + "-fx-background-insets: 0; -fx-background-radius: 3;");
+
+ // 保存计数器引用供 doCompare 更新
+ // 用 userData 挂载,避免新增字段
+ webView.setUserData(diffCounter);
+
+ VBox panel = new VBox(3, header, webView);
+ VBox.setVgrow(webView, Priority.ALWAYS);
+ return panel;
+ }
+
+ // ==================== 功能逻辑 ====================
+
+ private void doCompare() {
+ String text1 = leftArea.getText();
+ String text2 = rightArea.getText();
+
+ if (text1.isEmpty() && text2.isEmpty()) {
+ setStatus("请在左侧和右侧粘贴文本后再对比", true);
+ return;
+ }
+
+ List lines1 = Arrays.asList(text1.split("\n", -1));
+ List lines2 = Arrays.asList(text2.split("\n", -1));
+ DiffPlugin.DiffResult lineDiff = plugin.computeDiff(lines1, lines2);
+
+ // 构建字符级差异 HTML + 纯文本结果
+ DiffRender render = buildDiffHtmlWithPlain(lines1, lines2, lineDiff);
+ lastPlainResult = render.plainText;
+ currentDiffIdx = -1;
+ lastHighlightId = null; // 重置高亮状态
+
+ webEngine.loadContent(render.html);
+ setStatus("对比完成 — 新增 " + lineDiff.added
+ + " | 删除 " + lineDiff.removed
+ + " | 相同 " + lineDiff.unchanged, false);
+
+ // 更新差异计数器
+ Label counter = (Label) webView.getUserData();
+ if (counter != null) {
+ counter.setText("共 " + diffAnchorIds.size() + " 处差异");
+ }
+ }
+
+ private void swapText() {
+ String temp = leftArea.getText();
+ leftArea.setText(rightArea.getText());
+ rightArea.setText(temp);
+ refreshLineCount();
+ setStatus("已交换左右文本", false);
+ }
+
+ private void clearAll() {
+ leftArea.clear();
+ rightArea.clear();
+ webEngine.loadContent("粘贴文本后点击「开始对比」查看字符级差异");
+ lastPlainResult = "";
+ diffAnchorIds.clear();
+ currentDiffIdx = -1;
+ Label counter = (Label) webView.getUserData();
+ if (counter != null) counter.setText("");
+ refreshLineCount();
+ setStatus("已清空全部内容", false);
+ }
+
+ private void pasteFromClipboard(TextArea area) {
+ Clipboard cb = Clipboard.getSystemClipboard();
+ if (cb.hasString()) {
+ String text = cb.getString();
+ if (text != null && !text.isEmpty()) {
+ area.setText(text);
+ area.positionCaret(0);
+ }
+ }
+ }
+
+ private void copyResult() {
+ if (lastPlainResult.isEmpty()) {
+ setStatus("没有可复制的结果", true);
+ return;
+ }
+ ClipboardContent content = new ClipboardContent();
+ content.putString(lastPlainResult);
+ Clipboard.getSystemClipboard().setContent(content);
+ setStatus("结果已复制到剪贴板", false);
+ }
+
+ private void saveReport() {
+ if (lastPlainResult.isEmpty()) {
+ setStatus("没有可保存的内容", true);
+ return;
+ }
+ try {
+ String ts = LocalDateTime.now().format(DT_FMT);
+
+ // 弹出目录选择对话框
+ DirectoryChooser dirChooser = new DirectoryChooser();
+ dirChooser.setTitle("选择保存目录");
+ Path defaultDir = plugin.getExportBaseDir();
+ if (Files.exists(defaultDir)) {
+ dirChooser.setInitialDirectory(defaultDir.toFile());
+ }
+
+ File selectedFile = dirChooser.showDialog(stage);
+ Path exportDir = (selectedFile != null) ? selectedFile.toPath() : plugin.getExportBaseDir();
+
+ Files.createDirectories(exportDir);
+ Path file = exportDir.resolve("diff_paste_" + ts + ".txt");
+ Files.write(file, lastPlainResult.getBytes("UTF-8"));
+ setStatus("报告已保存: " + file.toString(), false);
+ } catch (IOException ex) {
+ setStatus("保存失败: " + ex.getMessage(), true);
+ }
+ }
+
+ // ==================== 差异跳转导航 ====================
+
+ private void jumpToNextDiff() {
+ if (diffAnchorIds.isEmpty()) {
+ setStatus("没有差异可跳转", true);
+ return;
+ }
+ currentDiffIdx++;
+ if (currentDiffIdx >= diffAnchorIds.size()) currentDiffIdx = 0; // 循环到第一处
+ scrollToDiff(currentDiffIdx);
+ }
+
+ private void jumpToPrevDiff() {
+ if (diffAnchorIds.isEmpty()) {
+ setStatus("没有差异可跳转", true);
+ return;
+ }
+ currentDiffIdx--;
+ if (currentDiffIdx < 0) currentDiffIdx = diffAnchorIds.size() - 1; // 循环到最后处
+ scrollToDiff(currentDiffIdx);
+ }
+
+ /** 上一次高亮的元素 ID,用于清除旧高亮 */
+ private String lastHighlightId = null;
+
+ /**
+ * 通过 JavaScript 滚动到指定锚点位置,使用 CSS 动画高亮
+ */
+ private void scrollToDiff(int idx) {
+ String anchorId = diffAnchorIds.get(idx);
+
+ // 先清除上一次的高亮
+ if (lastHighlightId != null && !lastHighlightId.isEmpty()) {
+ String clearJs = "var prev=document.getElementById('" + lastHighlightId + "');"
+ + "if(prev){prev.classList.remove('diff-highlight');}";
+ webEngine.executeScript(clearJs);
+ }
+ lastHighlightId = anchorId;
+
+ // 滚动并添加高亮动画类
+ String js = "(function(){"
+ + "var el=document.getElementById('" + anchorId + "');"
+ + "if(el){"
+ + "el.scrollIntoView({block:'center',behavior:'smooth'});"
+ + "el.classList.remove('diff-highlight');"
+ + "void el.offsetWidth;"
+ + "el.classList.add('diff-highlight');"
+ + "}"
+ + "})();";
+ webEngine.executeScript(js);
+
+ Label counter = (Label) webView.getUserData();
+ if (counter != null) {
+ counter.setText((idx + 1) + " / " + diffAnchorIds.size());
+ }
+ setStatus("第 " + (idx + 1) + " 处差异(共 " + diffAnchorIds.size() + " 处)", false);
+ }
+
+ // ==================== 字符级 Diff 算法 ====================
+
+ /**
+ * 单次对比的所有渲染输出
+ */
+ private static class DiffRender {
+ final String html;
+ final String plainText;
+ DiffRender(String html, String plainText) { this.html = html; this.plainText = plainText; }
+ }
+
+ /**
+ * 构建 HTML(带字符级颜色标记)和纯文本结果
+ */
+ private DiffRender buildDiffHtmlWithPlain(List lines1, List lines2,
+ DiffPlugin.DiffResult lineDiff) {
+
+ // 先把行级结果转为可迭代列表,标记每行的原始索引(行号从 1 开始)
+ List items = new ArrayList<>();
+ int i1 = 0, i2 = 0;
+ for (DiffPlugin.DiffLine dl : lineDiff.lines) {
+ switch (dl.type) {
+ case UNCHANGED:
+ items.add(new LineItem(DiffPlugin.DiffType.UNCHANGED, dl.content, i1 + 1, i2 + 1));
+ i1++; i2++;
+ break;
+ case REMOVED:
+ items.add(new LineItem(DiffPlugin.DiffType.REMOVED, lines1.get(i1), i1 + 1, -1));
+ i1++;
+ break;
+ case ADDED:
+ items.add(new LineItem(DiffPlugin.DiffType.ADDED, lines2.get(i2), -1, i2 + 1));
+ i2++;
+ break;
+ }
+ }
+
+ // 将相邻的 REMOVED/ADDED 行配对为 MODIFIED(用于字符级对比)
+ List paired = pairConsecutiveChanges(items);
+
+ // 构建 HTML
+ StringBuilder html = new StringBuilder();
+ html.append("");
+
+ // 表头
+ html.append("");
+ html.append("");
+ html.append("");
+ html.append("");
+ html.append("
");
+
+ // 纯文本结果
+ StringBuilder plain = new StringBuilder();
+ // 首行:统计信息
+ plain.append("【对比统计】新增 ").append(lineDiff.added)
+ .append(" 行 | 删除 ").append(lineDiff.removed)
+ .append(" 行 | 相同 ").append(lineDiff.unchanged).append(" 行\n");
+ plain.append("────────────────────────────────────────────────────────────\n");
+ diffAnchorIds.clear(); // 每次对比重置
+ int diffSeq = 0; // 差异行序号
+
+ // 计算内容列最大长度(用于对齐)
+ int maxContentLen = paired.stream()
+ .mapToInt(item -> {
+ int len1 = item.content != null ? item.content.length() : 0;
+ int len2 = item.modifiedContent != null ? item.modifiedContent.length() : 0;
+ return Math.max(len1, len2);
+ })
+ .max()
+ .orElse(40);
+ // 限制最大宽度避免过长
+ maxContentLen = Math.min(maxContentLen, 80);
+ final int colWidth = maxContentLen;
+
+ for (LineItem item : paired) {
+ String diffId = null;
+ // 行号格式化(4位右对齐,不足补空格)
+ String leftNo = item.leftLineNo > 0 ? String.format("%4d", item.leftLineNo) : " -";
+ String rightNo = item.rightLineNo > 0 ? String.format("%4d", item.rightLineNo) : " -";
+
+ switch (item.type) {
+ case UNCHANGED:
+ html.append("");
+ html.append("| ").append(item.leftLineNo).append(" | ");
+ html.append("").append(htmlEscape(item.content)).append(" | ");
+ html.append("").append(htmlEscape(item.content)).append(" | ");
+ html.append("
");
+ plain.append("原文").append(leftNo).append(" | 对比").append(rightNo)
+ .append(" | [相同] ").append(padRight(item.content, colWidth)).append("\n");
+ break;
+
+ case REMOVED:
+ diffId = "diff-" + (diffSeq++);
+ diffAnchorIds.add(diffId);
+ html.append("");
+ html.append("| ").append(item.leftLineNo).append(" | ");
+ html.append("").append(htmlEscape(item.content)).append(" | ");
+ html.append(" | ");
+ html.append("
");
+ plain.append("原文").append(leftNo).append(" | 对比").append(rightNo)
+ .append(" | [删除] ").append(padRight(item.content, colWidth)).append("\n");
+ break;
+
+ case ADDED:
+ diffId = "diff-" + (diffSeq++);
+ diffAnchorIds.add(diffId);
+ html.append("");
+ html.append("| ").append(item.rightLineNo).append(" | ");
+ html.append(" | ");
+ html.append("").append(htmlEscape(item.content)).append(" | ");
+ html.append("
");
+ plain.append("原文").append(leftNo).append(" | 对比").append(rightNo)
+ .append(" | [新增] ").append(padRight(item.content, colWidth)).append("\n");
+ break;
+
+ case MODIFIED:
+ diffId = "diff-" + (diffSeq++);
+ diffAnchorIds.add(diffId);
+ CharDiffResult cdr = computeCharDiff(item.content, item.modifiedContent);
+ html.append("");
+ html.append("| ").append(item.leftLineNo).append(" | ");
+ html.append("").append(cdr.leftHtml).append(" | ");
+ html.append("").append(cdr.rightHtml).append(" | ");
+ html.append("
");
+ String padded1 = padRight(item.content, colWidth);
+ String padded2 = padRight(item.modifiedContent, colWidth);
+ plain.append("原文").append(leftNo).append(" | 对比").append(rightNo)
+ .append(" | [修改] \n");
+ plain.append(" - ").append(padded1).append("\n");
+ plain.append(" + ").append(padded2).append("\n");
+ break;
+ }
+ }
+
+ html.append("
");
+
+ return new DiffRender(html.toString(), plain.toString());
+ }
+
+ /** 扩展的差异类型 */
+ private enum LineType { UNCHANGED, REMOVED, ADDED, MODIFIED }
+
+ /** 差异行 */
+ private static class LineItem {
+ final LineType type;
+ final String content;
+ final String modifiedContent; // MODIFIED 时的另一侧内容
+ final int leftLineNo; // 原文行号(1-based),无则 -1
+ final int rightLineNo; // 对比文行号(1-based),无则 -1
+
+ LineItem(DiffPlugin.DiffType type, String content, int leftLineNo, int rightLineNo) {
+ this.type = convert(type);
+ this.content = content;
+ this.modifiedContent = null;
+ this.leftLineNo = leftLineNo;
+ this.rightLineNo = rightLineNo;
+ }
+
+ LineItem(String leftContent, String rightContent, int leftLineNo, int rightLineNo) {
+ this.type = LineType.MODIFIED;
+ this.content = leftContent;
+ this.modifiedContent = rightContent;
+ this.leftLineNo = leftLineNo;
+ this.rightLineNo = rightLineNo;
+ }
+
+ private static LineType convert(DiffPlugin.DiffType t) {
+ switch (t) {
+ case UNCHANGED: return LineType.UNCHANGED;
+ case REMOVED: return LineType.REMOVED;
+ case ADDED: return LineType.ADDED;
+ default: return LineType.UNCHANGED;
+ }
+ }
+ }
+
+ /** 右对齐方法:左对齐字符串,不足补空格,超过截断 */
+ private static String padRight(String s, int width) {
+ if (s == null) s = "";
+ if (s.length() >= width) {
+ return s.substring(0, width);
+ }
+ StringBuilder sb = new StringBuilder(s);
+ while (sb.length() < width) {
+ sb.append(' ');
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 将连续的 REMOVED/ADDED 配对为 MODIFIED
+ * 规则:连续的一批 REMOVED 行和紧随其后的连续 ADDED 行按顺序一一配对
+ */
+ private List pairConsecutiveChanges(List items) {
+ List result = new ArrayList<>();
+ int idx = 0;
+ while (idx < items.size()) {
+ LineItem cur = items.get(idx);
+ if (cur.type == LineType.REMOVED) {
+ // 收集连续 REMOVED
+ List removedBlock = new ArrayList<>();
+ while (idx < items.size() && items.get(idx).type == LineType.REMOVED) {
+ removedBlock.add(items.get(idx));
+ idx++;
+ }
+ // 收集紧随的连续 ADDED
+ List addedBlock = new ArrayList<>();
+ while (idx < items.size() && items.get(idx).type == LineType.ADDED) {
+ addedBlock.add(items.get(idx));
+ idx++;
+ }
+ // 一一配对,计算相似度决定是否配对为 MODIFIED
+ int pairCount = Math.min(removedBlock.size(), addedBlock.size());
+ for (int k = 0; k < pairCount; k++) {
+ LineItem r = removedBlock.get(k);
+ LineItem a = addedBlock.get(k);
+ double sim = charSimilarity(r.content, a.content);
+ if (sim >= CHAR_SIMILARITY_THRESHOLD) {
+ result.add(new LineItem(r.content, a.content, r.leftLineNo, a.rightLineNo)); // MODIFIED
+ } else {
+ result.add(r); // 保留 REMOVED
+ result.add(a); // 保留 ADDED
+ }
+ }
+ // 多余的 REMOVED
+ for (int k = pairCount; k < removedBlock.size(); k++) {
+ result.add(removedBlock.get(k));
+ }
+ // 多余的 ADDED
+ for (int k = pairCount; k < addedBlock.size(); k++) {
+ result.add(addedBlock.get(k));
+ }
+ } else {
+ result.add(cur);
+ idx++;
+ }
+ }
+ return result;
+ }
+
+ /** 计算两行文本的字符级相似度 (0~1),基于 LCS */
+ private double charSimilarity(String a, String b) {
+ if (a.isEmpty() && b.isEmpty()) return 1.0;
+ int maxLen = Math.max(a.length(), b.length());
+ if (maxLen == 0) return 1.0;
+ int lcsLen = lcsLength(a, b);
+ return (double) lcsLen / maxLen;
+ }
+
+ /** LCS 长度(带缓存优化) */
+ private int lcsLength(String a, String b) {
+ int m = a.length(), n = b.length();
+ if (m == 0 || n == 0) return 0;
+ // 用滚动数组优化空间
+ int[] prev = new int[n + 1];
+ for (int i = 1; i <= m; i++) {
+ int[] curr = new int[n + 1];
+ char ca = a.charAt(i - 1);
+ for (int j = 1; j <= n; j++) {
+ if (ca == b.charAt(j - 1)) {
+ curr[j] = prev[j - 1] + 1;
+ } else {
+ curr[j] = Math.max(prev[j], curr[j - 1]);
+ }
+ }
+ prev = curr;
+ }
+ return prev[n];
+ }
+
+ /**
+ * 字符级差异结果
+ */
+ private static class CharDiffResult {
+ final String leftHtml;
+ final String rightHtml;
+ CharDiffResult(String leftHtml, String rightHtml) {
+ this.leftHtml = leftHtml;
+ this.rightHtml = rightHtml;
+ }
+ }
+
+ /** 字符级片段类型 */
+ private enum CharSegType { EQ, DEL, INS }
+
+ private static class CharSegment {
+ final CharSegType type;
+ final String text;
+ CharSegment(CharSegType type, String text) { this.type = type; this.text = text; }
+ }
+
+ /**
+ * 计算两行文本的字符级差异,返回带 标记的 HTML
+ */
+ private CharDiffResult computeCharDiff(String left, String right) {
+ if (left.equals(right)) {
+ return new CharDiffResult(htmlEscape(left), htmlEscape(right));
+ }
+
+ // 计算 LCS 回溯路径
+ int m = left.length(), n = right.length();
+ int[][] dp = new int[m + 1][n + 1];
+ for (int i = 1; i <= m; i++) {
+ char ca = left.charAt(i - 1);
+ for (int j = 1; j <= n; j++) {
+ if (ca == right.charAt(j - 1)) {
+ dp[i][j] = dp[i - 1][j - 1] + 1;
+ } else {
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
+ }
+ }
+ }
+
+ // 回溯生成左右两侧的字符片段
+ List leftSegs = new ArrayList<>();
+ List rightSegs = new ArrayList<>();
+ int i = m, j = n;
+ while (i > 0 || j > 0) {
+ if (i > 0 && j > 0 && left.charAt(i - 1) == right.charAt(j - 1)) {
+ // EQ: 相同字符
+ prepend(leftSegs, new CharSegment(CharSegType.EQ, String.valueOf(left.charAt(i - 1))));
+ prepend(rightSegs, new CharSegment(CharSegType.EQ, String.valueOf(right.charAt(j - 1))));
+ i--; j--;
+ } else if (j > 0 && (i == 0 || dp[i][j - 1] >= dp[i - 1][j])) {
+ // INS: 右侧新增
+ prepend(rightSegs, new CharSegment(CharSegType.INS, String.valueOf(right.charAt(j - 1))));
+ j--;
+ } else {
+ // DEL: 左侧删除
+ prepend(leftSegs, new CharSegment(CharSegType.DEL, String.valueOf(left.charAt(i - 1))));
+ i--;
+ }
+ }
+
+ // 补齐左侧缺失的 EQ/INS(右侧删除的变为左侧 DEL,右侧新增的左侧为空)
+ // 合并生成完整左侧序列
+ List fullLeft = buildFullSide(leftSegs, rightSegs, CharSegType.DEL);
+ List fullRight = buildFullSideForRight(leftSegs, rightSegs);
+
+ // 合并连续同类型片段
+ fullLeft = mergeSegments(fullLeft);
+ fullRight = mergeSegments(fullRight);
+
+ String leftHtml = segmentsToHtml(fullLeft, CharSegType.DEL, "char-removed");
+ String rightHtml = segmentsToHtml(fullRight, CharSegType.INS, "char-added");
+
+ return new CharDiffResult(leftHtml, rightHtml);
+ }
+
+ /**
+ * 合并两侧回溯结果,生成"左列"的完整片段序列
+ */
+ private List buildFullSide(List left, List right, CharSegType delType) {
+ // 把右侧非 EQ 片段对应的左侧设为空片段(DEL)
+ List result = new ArrayList<>(left);
+ // 左列中如果右侧有 INS 而左侧没有对应 → 不需要处理,因为左侧为空白
+ // 实际上我们只需让左侧显示它的片段,右侧 INS 啥也不用对应
+ return result;
+ }
+
+ private List buildFullSideForRight(List left, List right) {
+ // 右列显示它的片段,左侧 DEL 对应的右侧为空
+ return new ArrayList<>(right);
+ }
+
+ private void prepend(List list, CharSegment seg) {
+ list.add(0, seg);
+ }
+
+ /** 合并相邻同类型片段 */
+ private List mergeSegments(List segs) {
+ List merged = new ArrayList<>();
+ for (CharSegment seg : segs) {
+ if (!merged.isEmpty() && merged.get(merged.size() - 1).type == seg.type) {
+ CharSegment last = merged.remove(merged.size() - 1);
+ merged.add(new CharSegment(last.type, last.text + seg.text));
+ } else {
+ merged.add(seg);
+ }
+ }
+ return merged;
+ }
+
+ /** 将片段列表转为 HTML */
+ private String segmentsToHtml(List segs, CharSegType highlightType, String cssClass) {
+ StringBuilder sb = new StringBuilder();
+ for (CharSegment seg : segs) {
+ String escaped = htmlEscape(seg.text);
+ if (seg.type == highlightType) {
+ sb.append("").append(escaped).append("");
+ } else {
+ sb.append(escaped);
+ }
+ }
+ return sb.toString();
+ }
+
+ // ==================== 辅助方法 ====================
+
+ private static String htmlEscape(String s) {
+ if (s.isEmpty()) return "";
+ return s.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'")
+ .replace(" ", " ");
+ }
+
+ private void refreshLineCount() {
+ int lc = leftArea.getText().isEmpty() ? 0 : leftArea.getText().split("\n", -1).length;
+ int rc = rightArea.getText().isEmpty() ? 0 : rightArea.getText().split("\n", -1).length;
+ Platform.runLater(() -> setStatus("原文: " + lc + " 行 | 对比文: " + rc + " 行", false));
+ }
+
+ private void setStatus(String msg, boolean isError) {
+ if (statusLabel != null) {
+ statusLabel.setText(msg);
+ statusLabel.setTextFill(isError ? Color.rgb(211, 47, 47) : Color.rgb(117, 117, 117));
+ }
+ }
+
+ // ==================== 按钮样式 ====================
+
+ private Button accentButton(String text) {
+ Button btn = new Button(text);
+ btn.setFont(Font.font("System", 15));
+ btn.setStyle("-fx-background-color: #1976D2; -fx-text-fill: white;"
+ + "-fx-font-weight: bold; -fx-padding: 8 24 8 24;"
+ + "-fx-background-radius: 4; -fx-cursor: hand;");
+ return btn;
+ }
+
+ private Button normalButton(String text) {
+ Button btn = new Button(text);
+ btn.setFont(Font.font("System", 14));
+ btn.setStyle("-fx-background-color: #f5f5f5; -fx-text-fill: #424242;"
+ + "-fx-padding: 8 18 8 18;"
+ + "-fx-background-radius: 4; -fx-cursor: hand;");
+ return btn;
+ }
+
+ private Button tinyButton(String text) {
+ Button btn = new Button(text);
+ btn.setFont(Font.font("System", 12));
+ btn.setStyle("-fx-background-color: #f5f5f5; -fx-text-fill: #616161;"
+ + "-fx-padding: 3 10 3 10;"
+ + "-fx-background-radius: 3; -fx-cursor: hand;");
+ return btn;
+ }
+}
diff --git a/项目目录结构说明.md b/项目目录结构说明.md
index 238a017..1ab8032 100644
--- a/项目目录结构说明.md
+++ b/项目目录结构说明.md
@@ -43,20 +43,20 @@ src/main/java/com/example/workutils/
│ │ └── WorkUtilsProperties.java # 全局配置属性类
│ ├── support/ # 共享基础设施
│ │ ├── ConsoleHelper.java # 控制台交互工具类
-│ │ ├── FileOperationService.java# 文件操作服务(读取/扫描/写入/路径管理)
-│ │ └── OutputService.java # 输出服务(CSV/Excel)
-│ ├── plugin/ # Tool 基础契约
-│ │ └── Tool.java # 工具接口(合并了原 Plugin,各 biz 模块直接实现)
+│ │ ├── FileOperationHelper.java # 文件操作工具类(读取/扫描/写入/路径管理)
+│ │ └── OutputHelper.java # 输出工具类(CSV/Excel)
│ └── plugin/ # 插件化框架
+│ ├── Plugin.java # 插件接口(生命周期 + 菜单 + 命令的统一契约)
│ ├── PluginDescriptor.java # 插件描述符(id/name/version/description/dependencies)
│ ├── PluginContext.java # 插件运行时上下文
│ ├── PluginManager.java # 插件管理器(生命周期 + 注册 + 主菜单 + 命令分发)
│ └── PluginState.java # 插件状态枚举(UNLOADED/LOADED/ACTIVE/DISABLED/ERROR)
-└── biz/ # 业务插件层(各模块实现 Tool 接口)
- ├── batch/ # 批量处理插件
- │ └── BatchTool.java # 批量检查入口(实现 Tool)
+└── biz/ # 业务插件层(各模块实现 Plugin 接口)
+ ├── diff/ # 文本对比插件
+ │ ├── DiffPlugin.java # 文本差异对比(实现 Plugin),支持可视化窗口
+ │ └── DiffWindow.java # Swing 可视化对比窗口(左右粘贴文本,一键对比)
├── git/ # Git 操作插件
- │ ├── GitTool.java # Git 插件入口(实现 Tool)
+ │ ├── GitPlugin.java # Git 插件入口(实现 Plugin)
│ └── service/ # 服务实现层
│ ├── GitService.java # Git 服务接口
│ ├── GitServiceImpl.java # Git 服务实现
@@ -75,20 +75,20 @@ src/main/java/com/example/workutils/
│ ├── CheckBranchChangesStrategy.java
│ └── CheckoutAndPullStrategy.java
├── mybatisexport/ # MyBatis SQL导出插件
- │ ├── MyBatisExportTool.java # SQL 导出入口(实现 Tool)
+ │ ├── MyBatisExportPlugin.java # SQL 导出入口(实现 Plugin)
│ └── service/
│ ├── MyBatisExportService.java # SQL 导出服务
│ └── model/
│ └── MybatisTag.java # MyBatis 标签数据对象
├── mybatisgenerator/ # MyBatis逆向工程插件
- │ ├── MyBatisGeneratorTool.java # 逆向工程入口(实现 Tool)
+ │ ├── MyBatisGeneratorPlugin.java # 逆向工程入口(实现 Plugin)
│ └── service/generator/
│ ├── MyBatisGeneratorService.java # 逆向工程服务
│ ├── CustomCommentGenerator.java # 自定义注释生成器
│ ├── LombokPlugin.java # Lombok 插件(@Data)
│ └── SelectiveOnlyPlugin.java # 只保留 Selective 方法插件
└── scanner/ # 扫描插件
- ├── ScannerTool.java # 扫描工具入口(实现 Tool)
+ ├── ScannerPlugin.java # 扫描工具入口(实现 Plugin)
└── service/ # 服务实现层
├── ScannerService.java # 扫描服务
└── config/
@@ -116,25 +116,19 @@ src/main/java/com/example/workutils/
| 文件 | 作用 |
|------|------|
-| `Tool.java` | **工具接口**,合并了原 Plugin,包含生命周期方法 `onLoad(PluginContext)` / `onUnload()`,`getId()`/`getName()` 默认委托给 `PluginDescriptor` |
+| `Plugin.java` | **插件接口**,定义 `getDescriptor()`、`getId()`、`getName()`、`getMenuOptions()`、`execute()`、`handleChoice()`、`getCommandName()`、`executeCommand()`、`onLoad(PluginContext)`、`onUnload()` 等完整契约 |
| `PluginDescriptor.java` | 插件描述符(不可变),包含 `id`、`name`、`version`、`description`、`dependencies`,通过 Builder 模式构建 |
| `PluginContext.java` | 插件运行时上下文,提供 `PluginManager` 引用和跨插件共享属性 |
| `PluginManager.java` | **插件管理器**(`@Component`),负责:生命周期管理(load/enable/disable)、注册发现(按 id/command 查找)、启用过滤(白名单/黑名单)、主菜单交互循环、命令行分发 |
| `PluginState.java` | 插件状态枚举:`UNLOADED` → `LOADED` → `ACTIVE` / `DISABLED` / `ERROR` |
-#### 3.2.2 Tool 基础契约(`system/plugin/`)
-
-#### `Tool.java`
-- **作用**: 工具接口,定义 `getDescriptor()`、`getId()`、`getName()`、`getMenuOptions()`、`execute()`、`handleChoice()`、`getCommandName()`、`executeCommand()`、`onLoad(PluginContext)`、`onUnload()` 等方法
-- **关系**: biz 模块直接实现 `Tool`
-
#### 3.2.3 共享基础设施(`system/support/`)
| 文件 | 作用 |
|------|------|
| `ConsoleHelper.java` | 控制台交互工具类(静态方法),统一控制台 I/O |
-| `FileOperationService.java` | 文件操作服务,提供文件读取、扫描、写入、项目路径管理等基础能力 |
-| `OutputService.java` | 输出服务,负责将数据输出为 CSV(OpenCSV)或 Excel(EasyExcel)格式 |
+| `FileOperationHelper.java` | 文件操作工具类,提供文件读取、扫描、写入、项目路径管理等基础能力 |
+| `OutputHelper.java` | 输出工具类,负责将数据输出为 CSV(OpenCSV)或 Excel(EasyExcel)格式 |
#### 3.2.4 全局配置(`system/config/`)
@@ -146,13 +140,13 @@ src/main/java/com/example/workutils/
### 3.3 业务插件层(`biz/`)
-> **架构约定**: 每个 biz 模块的 Tool 类放在模块根目录,实现 `Tool` 接口。通过静态 `PluginDescriptor` 声明插件元数据(id/name/version/description)。所有服务实现及附属文件收在 `service/` 子包下,内部细节用 `internal/` 隔离。
+> **架构约定**: 每个 biz 模块的 Plugin 类放在模块根目录,实现 `Plugin` 接口。通过静态 `PluginDescriptor` 声明插件元数据(id/name/version/description)。所有服务实现及附属文件收在 `service/` 子包下,内部细节用 `internal/` 隔离。
#### 3.3.1 扫描插件(`biz/scanner/`)
| 文件 | 作用 |
|------|------|
-| `ScannerTool.java` | 扫描插件入口(实现 Tool),版本 `1.0.0`,负责菜单交互,委托 ScannerService 执行业务逻辑 |
+| `ScannerPlugin.java` | 扫描插件入口(实现 Plugin),版本 `1.0.0`,负责菜单交互,委托 ScannerService 执行业务逻辑 |
| `service/ScannerService.java` | 扫描服务,支持关键词扫描、Redis Key 扫描、Dubbo 接口扫描三种类型,支持并发多项目扫描 |
| `service/config/ScanConfig.java` | 扫描配置,定义文件扩展名、排除目录、最大文件大小等参数 |
@@ -160,7 +154,7 @@ src/main/java/com/example/workutils/
| 文件 | 作用 |
|------|------|
-| `MyBatisExportTool.java` | SQL 导出插件入口(实现 Tool),版本 `1.0.0`,负责菜单交互 |
+| `MyBatisExportPlugin.java` | SQL 导出插件入口(实现 Plugin),版本 `1.0.0`,负责菜单交互 |
| `service/MyBatisExportService.java` | SQL 导出核心服务,从 XML 文件中提取 select/insert/update/delete 标签 |
| `service/model/MybatisTag.java` | MyBatis 标签数据对象 |
@@ -168,7 +162,7 @@ src/main/java/com/example/workutils/
| 文件 | 作用 |
|------|------|
-| `MyBatisGeneratorTool.java` | 逆向工程插件入口(实现 Tool),版本 `1.0.0` |
+| `MyBatisGeneratorPlugin.java` | 逆向工程插件入口(实现 Plugin),版本 `1.0.0` |
| `service/generator/MyBatisGeneratorService.java` | MyBatis 逆向工程服务 |
| `service/generator/CustomCommentGenerator.java` | 自定义注释生成器 |
| `service/generator/LombokPlugin.java` | Lombok 插件(@Data) |
@@ -178,7 +172,7 @@ src/main/java/com/example/workutils/
| 文件 | 作用 |
|------|------|
-| `GitTool.java` | Git 插件入口(实现 Tool),版本 `1.0.0` |
+| `GitPlugin.java` | Git 插件入口(实现 Plugin),版本 `1.0.0` |
| `service/GitService.java` | Git 服务接口 |
| `service/GitServiceImpl.java` | Git 服务实现 |
| `service/config/GitOptimizationConfig.java` | Git 性能优化配置 |
@@ -189,11 +183,20 @@ src/main/java/com/example/workutils/
| `service/internal/GitResultCache.java` | Git 结果缓存管理器 |
| `service/internal/strategy/*` | 策略模式实现 |
-#### 3.3.4 批量插件(`biz/batch/`)
+#### 3.3.4 文本对比插件(`biz/diff/`)
| 文件 | 作用 |
|------|------|
-| `BatchTool.java` | 批量处理插件入口(实现 Tool),版本 `1.0.0`,一键执行多种检查 |
+| `DiffPlugin.java` | 文本对比插件入口(实现 Plugin),版本 `1.0.0`,基于 LCS 算法行级精确对比,支持**粘贴可视化窗口**、文件对比、目录对比三种模式 |
+| `DiffWindow.java` | **JavaFX** 可视化对比窗口。上半区粘贴原文/对比文,下半区 **WebView** 渲染字符级差异(浅红背景=删除字符,浅绿背景=新增字符,无色=相同),支持行+字符两级 LCS 算法 |
+
+| 功能 | 快捷键 | 说明 |
+|------|--------|------|
+| 开始对比 | `Ctrl+Enter` | 将左右文本区的内容进行行级对比 |
+| 交换左右 | `Ctrl+L` | 交换左右两侧文本 |
+| 粘贴 | 点击按钮 | 从系统剪贴板粘贴到对应文本区 |
+| 复制结果 | 点击按钮 | 将对比结果复制到剪贴板 |
+| 保存报告 | 点击按钮 | 保存为 `export/yyyyMMdd/diff_paste_时间戳.txt` |
---
@@ -223,18 +226,18 @@ src/main/resources/
| 模式/特点 | 应用位置 | 说明 |
|-----------|---------|------|
-| **插件架构** | `Tool` + `PluginManager` + `PluginDescriptor` | 每个 biz 模块作为独立 Tool,声明版本、描述,PluginManager 统一管理生命周期(load/enable/disable),可插拔扩展 |
-| **策略模式** | `Tool` 接口 + 各 biz 实现 | 各插件通过实现 Tool 接口注册到 PluginManager,支持交互和命令行双模式 |
+| **插件架构** | `Plugin` + `PluginManager` + `PluginDescriptor` | 每个 biz 模块作为独立 Plugin,声明版本、描述,PluginManager 统一管理生命周期(load/enable/disable),可插拔扩展 |
+| **策略模式** | `Plugin` 接口 + 各 biz 实现 | 各插件通过实现 Plugin 接口注册到 PluginManager,支持交互和命令行双模式 |
| **策略模式** | `GitCommandStrategy` + 各 Strategy 实现 | Git 命令执行策略,`GitStrategyFactory` 管理注册 |
| **工厂模式** | `PluginManager`、`GitStrategyFactory` | Spring 自动发现和注入 Bean |
| **命令模式** | `GitCommand` + `GitCommandController` | 封装 Git 命令的异步执行、中断、跳过 |
| **缓存模式** | `GitResultCache` | 缓存 Git 查询结果,减少重复命令执行 |
| **并发处理** | `GitCommandExecutor`、`ScannerService` | 线程池并发执行 Git 命令和多项目扫描 |
| **过滤器模式** | `MyBatisExportService` 内部类 | `SqlFilter` 接口实现表名/关键字/标签ID 过滤 |
-| **模板回调** | `FileOperationService.scanFilesRecursively` | 使用 `SimpleFileVisitor` 实现文件遍历回调 |
+| **模板回调** | `FileOperationHelper.scanFilesRecursively` | 使用 `SimpleFileVisitor` 实现文件遍历回调 |
| **分层架构** | `system/` vs `biz/` | system 定义平台契约与共享基础设施,biz 实现具体插件 |
| **内部隔离** | `biz/*/service/internal/` | 内部实现类用 `internal/` 隔离,不对外暴露 |
-| **生命周期管理** | `Tool.onLoad()` / `Tool.onUnload()` | 插件加载/卸载回调,PluginContext 传递运行时上下文 |
+| **生命周期管理** | `Plugin.onLoad()` / `Plugin.onUnload()` | 插件加载/卸载回调,PluginContext 传递运行时上下文 |
---
@@ -244,32 +247,32 @@ src/main/resources/
WorkUtilsApplication (入口)
├── 交互模式 → PluginManager.runMainMenu()
│ └── PluginManager.getEnabledPlugins()
- │ ├── ScannerTool ────────────→ ScannerService
- │ ├── MyBatisExportTool ──────→ MyBatisExportService
- │ ├── MyBatisGeneratorTool ───→ MyBatisGeneratorService
- │ ├── GitTool ────────────────→ GitService → GitStrategyFactory
- │ └── BatchTool ──────────────→ ScannerService + MyBatisExportService
+ │ ├── ScannerPlugin ────────────→ ScannerService
+ │ ├── MyBatisExportPlugin ──────→ MyBatisExportService
+ │ ├── MyBatisGeneratorPlugin ───→ MyBatisGeneratorService
+ │ ├── GitPlugin ────────────────→ GitService → GitStrategyFactory
+ │ └── DiffPlugin ───────────────→ (自含LCS算法,无外部依赖)
│
└── 命令行模式 → PluginManager.getPluginByCommand(command)
- ├── "scan" → ScannerTool.executeCommand(args)
- ├── "sql" → MyBatisExportTool.executeCommand(args)
- ├── "batch" → BatchTool.executeCommand(args)
- └── "git" → GitTool.executeCommand(args)
+ ├── "scan" → ScannerPlugin.executeCommand(args)
+ ├── "sql" → MyBatisExportPlugin.executeCommand(args)
+ ├── "diff" → DiffPlugin.executeCommand(args)
+ └── "git" → GitPlugin.executeCommand(args)
(启动类不依赖任何具体业务 Service)
插件加载链路:
PluginManager.init()
- ├── 遍历 Spring 中所有 Tool Bean
+ ├── 遍历 Spring 中所有 Plugin Bean
├── 依次调用 plugin.onLoad(PluginContext)
├── 按 ToolsConfig 过滤启用/禁用
└── 构建 pluginByIdMap / pluginByCommandMap / pluginStates
核心依赖链:
- ScannerService → FileOperationService + OutputService
- MyBatisExportService → FileOperationService + OutputService
- GitService → FileOperationService + GitCommandExecutor
- OutputService → FileOperationService + WorkUtilsProperties
- FileOperationService → WorkUtilsProperties
+ ScannerService → FileOperationHelper + OutputHelper
+ MyBatisExportService → FileOperationHelper + OutputHelper
+ GitService → FileOperationHelper + GitCommandExecutor
+ OutputHelper → FileOperationHelper + WorkUtilsProperties
+ FileOperationHelper → WorkUtilsProperties
```
---
@@ -310,14 +313,14 @@ WorkUtilsApplication (入口)
│ onLoad(PluginContext)
┌──────────────┼──────────────┬──────────────┐
▼ ▼ ▼ ▼
- ScannerTool MyBatisExport MyBatisGen GitTool BatchTool
+ ScannerPlugin MyBatisExport MyBatisGen GitPlugin DiffPlugin
[v1.0.0] [v1.0.0] [v1.0.0] [v1.0.0] [v1.0.0]
```
### 8.2 接口层次
```
-Tool (system/plugin/) ← 统一契约:getDescriptor/onLoad/onUnload/getMenuOptions/execute/...
+Plugin (system/plugin/) ← 统一契约:getDescriptor/onLoad/onUnload/getMenuOptions/execute/...
↑
各 biz 模块 ← 具体实现:@Component + PluginDescriptor
```
@@ -349,7 +352,7 @@ UNLOADED ──load──▶ LOADED ──enable──▶ ACTIVE ──disable
当前已完成插件化基础框架搭建,后续可进一步实现:
-1. **SPI 自动发现** — 通过 `META-INF/services` 或 Spring Factories 自动发现外部 jar 中的 Tool 实现
+1. **SPI 自动发现** — 通过 `META-INF/services` 或 Spring Factories 自动发现外部 jar 中的 Plugin 实现
2. **独立打包** — 每个 biz 模块打成独立 jar,`PluginManager` 动态加载
3. **热加载** — 基于自定义 ClassLoader 实现插件的动态加载/卸载/更新
4. **依赖注入隔离** — 每个插件使用独立 Spring 子容器(`ApplicationContext`),避免 Bean 冲突