①删除了批量处理的插件
②新增文本对比的插件
This commit is contained in:
@@ -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 <file1> <file2>} 直接输出差异</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("&", "&")
|
||||
.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user