①删除了批量处理的插件

②新增文本对比的插件
This commit is contained in:
张家训
2026-06-09 17:31:13 +08:00
parent d14c2fbebf
commit 49873d5948
5 changed files with 1122 additions and 214 deletions
+8
View File
@@ -171,6 +171,14 @@
</exclusions>
</dependency>
<!-- JavaFX 8(OracleJDK8 内置;OpenJDK8 需安装 OpenJFX 后调整路径) -->
<dependency>
<groupId>com.oracle</groupId>
<artifactId>javafx</artifactId>
<version>8.0</version>
<scope>system</scope>
<systemPath>${java.home}/lib/ext/jfxrt.jar</systemPath>
</dependency>
</dependencies>
@@ -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|<dubbo:"));
executeStep("3. 扫描Redis操作...",
() -> 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<String> 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<String> parseCommaSeparatedInput(String input) {
List<String> 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;
}
}
@@ -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.*;
/**
* 文本对比插件
* <p>
* 基于 LCS(最长公共子序列)算法实现行级精确文本对比,
* 输出格式兼容 unified diff,支持交互式和命令行两种模式:
* <ul>
* <li>交互模式 — 粘贴文本对比(JavaFX 可视化窗口) / 文件对比 / 目录对比</li>
* <li>命令行模式 — {@code diff &lt;file1&gt; &lt;file2&gt;} 直接输出差异</li>
* </ul>
*/
@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<DiffLine> lines;
final int added, removed, unchanged;
DiffResult(List<DiffLine> 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<String> a, List<String> 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<DiffLine> 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);
}
}
@@ -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 文本对比可视化窗口
* <p>
* 上半区:左右 TextArea 粘贴原文/对比文;
* 下半区:WebView 渲染字符级差异,行内按字符着色(浅红=删除,浅绿=新增,无色=未变)。
* <p>
* 快捷键: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<String> 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<String> lines1 = Arrays.asList(text1.split("\n", -1));
List<String> 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("<html><body style='font-family:Consolas,monospace;color:#888;padding:20px;'>粘贴文本后点击「开始对比」查看字符级差异</body></html>");
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<String> lines1, List<String> lines2,
DiffPlugin.DiffResult lineDiff) {
// 先把行级结果转为可迭代列表,标记每行的原始索引(行号从 1 开始)
List<LineItem> 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<LineItem> paired = pairConsecutiveChanges(items);
// 构建 HTML
StringBuilder html = new StringBuilder();
html.append("<!DOCTYPE html><html><head><meta charset='UTF-8'><style>");
html.append("*{margin:0;padding:0;box-sizing:border-box;}");
html.append("body{font-family:'Consolas','Courier New',monospace;font-size:13px;line-height:1.55;margin:0;}");
html.append(".diff-table{width:100%;border-collapse:collapse;table-layout:auto;}");
html.append(".diff-table td{padding:2px 4px;vertical-align:top;white-space:pre-wrap;word-break:break-all;transition:all 0.3s ease;}");
html.append(".ln-col{width:45px;min-width:45px;white-space:nowrap;text-align:right;color:#bbb;font-size:12px;padding-right:8px !important;user-select:none;border-right:1px solid #e0e0e0;}");
html.append(".left-col{width:50%;border-right:1px solid #e0e0e0;}");
html.append(".right-col{width:50%;}");
html.append(".line-removed{background-color:#ffeef0;color:#c53030;}");
html.append(".line-added{background-color:#e6ffed;color:#276749;}");
html.append(".line-unchanged{background-color:transparent;color:#374151;}");
html.append(".char-removed{background-color:#feb2b2;color:#c53030;border-radius:2px;padding:0 1px;}");
html.append(".char-added{background-color:#9ae6b4;color:#276749;border-radius:2px;padding:0 1px;}");
html.append(".col-header{background:#f5f5f5;color:#333;font-weight:bold;text-align:center;padding:8px 0;border-bottom:1px solid #ddd;position:sticky;top:0;z-index:10;}");
// 差异行高亮动画(用于跳转时)
html.append("@keyframes diffHighlight{");
html.append("0%{box-shadow:inset 0 0 0 3px #1976D2;background-color:#e3f2fd;}");
html.append("100%{box-shadow:inset 0 0 0 0 transparent;background-color:inherit;}");
html.append("}");
html.append(".diff-highlight{animation:diffHighlight 1.5s ease-out;}");
html.append("</style></head><body>");
// 表头
html.append("<table class='diff-table'><tr>");
html.append("<td class='col-header ln-col'>#</td>");
html.append("<td class='col-header left-col'>原文</td>");
html.append("<td class='col-header right-col'>对比文</td>");
html.append("</tr>");
// 纯文本结果
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("<tr class='line-unchanged'>");
html.append("<td class='ln-col'>").append(item.leftLineNo).append("</td>");
html.append("<td class='left-col'>").append(htmlEscape(item.content)).append("</td>");
html.append("<td class='right-col'>").append(htmlEscape(item.content)).append("</td>");
html.append("</tr>");
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("<tr id='").append(diffId).append("' class='line-removed'>");
html.append("<td class='ln-col'>").append(item.leftLineNo).append("</td>");
html.append("<td class='left-col'>").append(htmlEscape(item.content)).append("</td>");
html.append("<td class='right-col'></td>");
html.append("</tr>");
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("<tr id='").append(diffId).append("' class='line-added'>");
html.append("<td class='ln-col'>").append(item.rightLineNo).append("</td>");
html.append("<td class='left-col'></td>");
html.append("<td class='right-col'>").append(htmlEscape(item.content)).append("</td>");
html.append("</tr>");
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("<tr id='").append(diffId).append("'>");
html.append("<td class='ln-col'>").append(item.leftLineNo).append("</td>");
html.append("<td class='left-col' style='background:#fff5f6;'>").append(cdr.leftHtml).append("</td>");
html.append("<td class='right-col' style='background:#f0fdf4;'>").append(cdr.rightHtml).append("</td>");
html.append("</tr>");
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("</table></body></html>");
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<LineItem> pairConsecutiveChanges(List<LineItem> items) {
List<LineItem> result = new ArrayList<>();
int idx = 0;
while (idx < items.size()) {
LineItem cur = items.get(idx);
if (cur.type == LineType.REMOVED) {
// 收集连续 REMOVED
List<LineItem> removedBlock = new ArrayList<>();
while (idx < items.size() && items.get(idx).type == LineType.REMOVED) {
removedBlock.add(items.get(idx));
idx++;
}
// 收集紧随的连续 ADDED
List<LineItem> 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; }
}
/**
* 计算两行文本的字符级差异,返回带 <span> 标记的 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<CharSegment> leftSegs = new ArrayList<>();
List<CharSegment> 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<CharSegment> fullLeft = buildFullSide(leftSegs, rightSegs, CharSegType.DEL);
List<CharSegment> 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<CharSegment> buildFullSide(List<CharSegment> left, List<CharSegment> right, CharSegType delType) {
// 把右侧非 EQ 片段对应的左侧设为空片段(DEL)
List<CharSegment> result = new ArrayList<>(left);
// 左列中如果右侧有 INS 而左侧没有对应 → 不需要处理,因为左侧为空白
// 实际上我们只需让左侧显示它的片段,右侧 INS 啥也不用对应
return result;
}
private List<CharSegment> buildFullSideForRight(List<CharSegment> left, List<CharSegment> right) {
// 右列显示它的片段,左侧 DEL 对应的右侧为空
return new ArrayList<>(right);
}
private void prepend(List<CharSegment> list, CharSegment seg) {
list.add(0, seg);
}
/** 合并相邻同类型片段 */
private List<CharSegment> mergeSegments(List<CharSegment> segs) {
List<CharSegment> 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<CharSegment> segs, CharSegType highlightType, String cssClass) {
StringBuilder sb = new StringBuilder();
for (CharSegment seg : segs) {
String escaped = htmlEscape(seg.text);
if (seg.type == highlightType) {
sb.append("<span class='").append(cssClass).append("'>").append(escaped).append("</span>");
} else {
sb.append(escaped);
}
}
return sb.toString();
}
// ==================== 辅助方法 ====================
private static String htmlEscape(String s) {
if (s.isEmpty()) return "";
return s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
.replace("'", "&#39;")
.replace(" ", "&nbsp;");
}
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;
}
}
+52 -49
View File
@@ -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 冲突