From 49873d5948a1bf55ef33b7192953c4e833b0d84a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Tue, 9 Jun 2026 17:31:13 +0800 Subject: [PATCH] =?UTF-8?q?=E2=91=A0=E5=88=A0=E9=99=A4=E4=BA=86=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E5=A4=84=E7=90=86=E7=9A=84=E6=8F=92=E4=BB=B6=20?= =?UTF-8?q?=E2=91=A1=E6=96=B0=E5=A2=9E=E6=96=87=E6=9C=AC=E5=AF=B9=E6=AF=94?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 8 + .../workutils/biz/batch/BatchPlugin.java | 165 ---- .../workutils/biz/diff/DiffPlugin.java | 205 +++++ .../workutils/biz/diff/DiffWindow.java | 857 ++++++++++++++++++ 项目目录结构说明.md | 101 ++- 5 files changed, 1122 insertions(+), 214 deletions(-) delete mode 100644 src/main/java/com/example/workutils/biz/batch/BatchPlugin.java create mode 100644 src/main/java/com/example/workutils/biz/diff/DiffPlugin.java create mode 100644 src/main/java/com/example/workutils/biz/diff/DiffWindow.java diff --git a/pom.xml b/pom.xml index add1e46..b83e812 100644 --- a/pom.xml +++ b/pom.xml @@ -171,6 +171,14 @@ + + + com.oracle + javafx + 8.0 + system + ${java.home}/lib/ext/jfxrt.jar + diff --git a/src/main/java/com/example/workutils/biz/batch/BatchPlugin.java b/src/main/java/com/example/workutils/biz/batch/BatchPlugin.java deleted file mode 100644 index d297141..0000000 --- a/src/main/java/com/example/workutils/biz/batch/BatchPlugin.java +++ /dev/null @@ -1,165 +0,0 @@ -package com.example.workutils.biz.batch; - -import com.example.workutils.biz.mybatisexport.service.MyBatisExportService; -import com.example.workutils.biz.scanner.service.ScannerService; -import com.example.workutils.system.plugin.Plugin; -import com.example.workutils.system.plugin.PluginDescriptor; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.Scanner; - -/** - * 批量处理插件(实现 Tool 接口) - * 仅负责菜单展示和用户交互,业务逻辑委托给各Service - - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class BatchPlugin implements Plugin { - - @FunctionalInterface - public interface ExceptionRunnable { - void run() throws Exception; - } - - private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() - .id("batch") - .name("批量处理") - .version("1.0.0") - .description("一键执行多种检查任务") - .build(); - - private static final String[] MENU_OPTIONS = { - "1. 执行所有检查", - "2. 自定义组合检查", - "0. 返回上级菜单" - }; - - private static final String[] CUSTOM_CHECK_OPTIONS = { - "1. SQL导出", - "2. Dubbo接口扫描", - "3. Redis操作扫描", - "4. 关键词扫描" - }; - - private final ScannerService scannerService; - private final MyBatisExportService myBatisExportService; - - @Override - public PluginDescriptor getDescriptor() { - return DESCRIPTOR; - } - - @Override - public String getCommandName() { - return "batch"; - } - - @Override - public String[] getMenuOptions() { - return MENU_OPTIONS; - } - - @Override - public void executeCommand(String... args) throws Exception { - log.info("命令行模式: 开始执行所有检查"); - executeAllChecks(); - System.out.println("批量处理完成!"); - } - - @Override - public void handleChoice(int choice, Scanner scanner) throws Exception { - switch (choice) { - case 1: - executeAllChecks(); - break; - case 2: - executeCustomChecks(scanner); - break; - default: - printInvalidOption(); - break; - } - } - - private void executeAllChecks() throws Exception { - printMenuTitle("执行所有检查"); - log.info("开始执行所有检查"); - - executeStep("1. 导出SQL...", () -> myBatisExportService.exportSQL()); - executeStep("2. 扫描Dubbo接口...", - () -> scannerService.scanDubboInterfacesWithDefaultName("@Reference|@Service| scannerService.scanRedisKeysWithDefaultName("redis\\.opsFor")); - - printSuccess("所有检查"); - log.info("所有检查执行完成"); - } - - private void executeCustomChecks(Scanner scanner) throws Exception { - printMenuTitle("自定义组合检查"); - displayMenu(CUSTOM_CHECK_OPTIONS); - - System.out.print("请选择要执行的检查(多选用逗号分隔,如1,2,3): "); - String choices = scanner.nextLine().trim(); - List choiceList = parseCommaSeparatedInput(choices); - - log.info("用户选择执行检查项: {}", choiceList); - - for (String choice : choiceList) { - try { - switch (choice) { - case "1": - executeStep("执行SQL导出...", () -> myBatisExportService.exportSQL()); - break; - case "2": - executeStep("执行Dubbo接口扫描...", - () -> scannerService.scanDubboInterfacesWithDefaultName("@Reference|@Service")); - break; - case "3": - executeStep("执行Redis操作扫描...", - () -> scannerService.scanRedisKeysWithDefaultName("redis\\.opsFor")); - break; - case "4": - System.out.print("请输入关键词: "); - String keyword = scanner.nextLine().trim(); - executeStep("执行关键词扫描...", - () -> scannerService.scanKeywordsWithDefaultName(keyword)); - break; - default: - System.out.println("跳过无效选项: " + choice); - break; - } - } catch (Exception e) { - log.error("执行检查项失败: " + choice, e); - System.out.println("检查项 " + choice + " 执行失败: " + e.getMessage()); - } - } - printSuccess("自定义检查"); - } - - private void executeStep(String stepName, ExceptionRunnable action) throws Exception { - System.out.println(stepName); - action.run(); - } - - private List parseCommaSeparatedInput(String input) { - List result = new ArrayList<>(); - if (input == null || input.trim().isEmpty()) { - return result; - } - String[] parts = input.split(","); - for (String part : parts) { - String trimmed = part.trim(); - if (!trimmed.isEmpty()) { - result.add(trimmed); - } - } - return result; - } -} diff --git a/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java new file mode 100644 index 0000000..331dfdb --- /dev/null +++ b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java @@ -0,0 +1,205 @@ +package com.example.workutils.biz.diff; + +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.PluginDescriptor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Path; +import java.util.*; + +/** + * 文本对比插件 + *

+ * 基于 LCS(最长公共子序列)算法实现行级精确文本对比, + * 输出格式兼容 unified diff,支持交互式和命令行两种模式: + *

    + *
  • 交互模式 — 粘贴文本对比(JavaFX 可视化窗口) / 文件对比 / 目录对比
  • + *
  • 命令行模式 — {@code diff <file1> <file2>} 直接输出差异
  • + *
+ */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DiffPlugin implements Plugin { + + private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() + .id("diff") + .name("文本对比") + .version("1.0.0") + .description("基于LCS算法的文本差异对比工具,支持行级精确对比") + .build(); + + private static final String[] MENU_OPTIONS = { + "1. 粘贴文本对比(弹出窗口)", + "0. 返回上级菜单" + }; + + private final WorkUtilsProperties properties; + + // ==================== Plugin 契约 ==================== + + @Override + public PluginDescriptor getDescriptor() { return DESCRIPTOR; } + + @Override + public String getCommandName() { return "diff"; } + + @Override + public String[] getMenuOptions() { return MENU_OPTIONS; } + + // ==================== JavaFX 初始化 ==================== + + /** + * Spring Boot 默认开启 headless 模式,Swing 无法初始化。 + * 这里用 JavaFX Application.launch 在后台线程启动 JavaFX 运行时, + * 后续通过 Platform.runLater 在 JavaFX 线程上创建窗口。 + */ + private static final java.util.concurrent.CountDownLatch JFX_READY = + new java.util.concurrent.CountDownLatch(1); + private static volatile boolean jfxFailed; + + static { + Thread jfxLauncher = new Thread(() -> { + try { + javafx.application.Platform.setImplicitExit(false); + javafx.application.Application.launch(JFXBootstrap.class); + } catch (Exception e) { + jfxFailed = true; + JFX_READY.countDown(); + } + }, "JavaFX-Bootstrap"); + jfxLauncher.setDaemon(true); + jfxLauncher.start(); + } + + /** 占位 Application,仅用于触发 JavaFX 工具包初始化 */ + public static class JFXBootstrap extends javafx.application.Application { + @Override + public void start(javafx.stage.Stage stage) { + JFX_READY.countDown(); + } + } + + // ==================== 交互模式 ==================== + + @Override + public void handleChoice(int choice, Scanner scanner) throws Exception { + switch (choice) { + case 1: openVisualDiff(); break; + default: printInvalidOption(); break; + } + } + + /** + * 启动 JavaFX 可视化对比窗口,阻塞直到窗口关闭 + */ + private void openVisualDiff() { + try { + JFX_READY.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (jfxFailed) { + System.err.println("JavaFX 初始化失败,请确认 JDK 8 包含 JavaFX 运行时(OracleJDK 内置;OpenJDK 需安装 OpenJFX)"); + return; + } + + System.out.println("正在打开文本对比窗口..."); + java.util.concurrent.CountDownLatch windowClose = new java.util.concurrent.CountDownLatch(1); + javafx.application.Platform.runLater(() -> { + try { + DiffWindow window = new DiffWindow(DiffPlugin.this, windowClose); + window.show(); + } catch (Exception e) { + System.err.println("窗口创建失败: " + e.getMessage()); + e.printStackTrace(); + windowClose.countDown(); // 异常也释放,避免无限阻塞 + } + }); + // 在控制台线程等待窗口关闭(不阻塞 JavaFX 渲染线程) + try { + windowClose.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + System.out.println("文本对比窗口已关闭"); + } + + /** 导出基础目录,供 DiffWindow 调用 */ + Path getExportBaseDir() { + return properties.getTodayExportPath(); + } + + // ==================== LCS 差异算法 ==================== + + enum DiffType { UNCHANGED, ADDED, REMOVED } + + static class DiffLine { + final DiffType type; + final String content; + DiffLine(DiffType type, String content) { this.type = type; this.content = content; } + } + + static class DiffResult { + final List lines; + final int added, removed, unchanged; + + DiffResult(List lines) { + this.lines = lines; + int a = 0, r = 0, u = 0; + for (DiffLine dl : lines) { + switch (dl.type) { + case ADDED: a++; break; + case REMOVED: r++; break; + default: u++; break; + } + } + this.added = a; + this.removed = r; + this.unchanged = u; + } + + boolean hasDifferences() { return added > 0 || removed > 0; } + } + + /** + * 基于 LCS (Longest Common Subsequence) 计算行级差异 + */ + DiffResult computeDiff(List a, List b) { + int m = a.size(), n = b.size(); + int[][] dp = new int[m + 1][n + 1]; + + for (int i = 1; i <= m; i++) { + for (int j = 1; j <= n; j++) { + if (a.get(i - 1).equals(b.get(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 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(""); + html.append(""); + html.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(""); + html.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(""); + html.append(""); + html.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(""); + html.append(""); + html.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("
#原文对比文
").append(item.leftLineNo).append("").append(htmlEscape(item.content)).append("").append(htmlEscape(item.content)).append("
").append(item.leftLineNo).append("").append(htmlEscape(item.content)).append("
").append(item.rightLineNo).append("").append(htmlEscape(item.content)).append("
").append(item.leftLineNo).append("").append(cdr.leftHtml).append("").append(cdr.rightHtml).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 冲突