From 4c844159f3185d3cb2ac1044523dde40b1fc57e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Fri, 19 Jun 2026 15:21:05 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E5=8F=AF=E7=94=A8=E7=89=88?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../系统设置重构-直接打开配置文件_16998c56.md | 228 +++++++ scripts/fix_urls.java | 27 + .../workutils/WorkUtilsApplication.java | 196 +++++- .../workutils/biz/apidoc/ApiDocPlugin.java | 41 ++ .../workutils/biz/apidoc/ApiDocWindow.java | 579 ++++++++++++++++++ .../workutils/biz/diff/DiffPlugin.java | 41 ++ .../workutils/biz/diff/DiffWindow.java | 132 ++-- .../workutils/biz/git/GitSyncPlugin.java | 244 +++++++- .../workutils/biz/git/GitSyncWindow.java | 512 ++++++++++++++++ .../mybatisexport/MyBatisExportPlugin.java | 43 ++ .../mybatisexport/MyBatisExportWindow.java | 433 +++++++++++++ .../service/MyBatisExportService.java | 74 +++ .../MyBatisGeneratorPlugin.java | 56 +- .../MyBatisGeneratorWindow.java | 128 ++-- .../workutils/biz/scanner/ScannerPlugin.java | 49 ++ .../workutils/biz/scanner/ScannerWindow.java | 458 ++++++++++++++ .../system/config/SettingsService.java | 7 + .../system/config/SettingsWindow.java | 94 ++- .../workutils/system/plugin/Plugin.java | 10 + .../system/plugin/PluginDescriptor.java | 5 + .../system/plugin/VisualPluginFactory.java | 32 + .../system/support/HistoryPersistence.java | 73 +++ .../workutils/system/ui/MainWindow.java | 474 ++++++++++++++ .../workutils/system/ui/StyledButton.java | 62 ++ .../example/workutils/system/ui/Theme.java | 170 +++++ .../resources/templates/project_paths.txt | 134 ++-- 26 files changed, 4050 insertions(+), 252 deletions(-) create mode 100644 .codebuddy/plans/系统设置重构-直接打开配置文件_16998c56.md create mode 100644 scripts/fix_urls.java create mode 100644 src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java create mode 100644 src/main/java/com/example/workutils/biz/git/GitSyncWindow.java create mode 100644 src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java create mode 100644 src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java create mode 100644 src/main/java/com/example/workutils/system/plugin/VisualPluginFactory.java create mode 100644 src/main/java/com/example/workutils/system/support/HistoryPersistence.java create mode 100644 src/main/java/com/example/workutils/system/ui/MainWindow.java create mode 100644 src/main/java/com/example/workutils/system/ui/StyledButton.java create mode 100644 src/main/java/com/example/workutils/system/ui/Theme.java diff --git a/.codebuddy/plans/系统设置重构-直接打开配置文件_16998c56.md b/.codebuddy/plans/系统设置重构-直接打开配置文件_16998c56.md new file mode 100644 index 0000000..a714a69 --- /dev/null +++ b/.codebuddy/plans/系统设置重构-直接打开配置文件_16998c56.md @@ -0,0 +1,228 @@ +--- +name: 系统设置重构-直接打开配置文件 +overview: 重构系统设置窗口,去掉繁琐的表单填写,改为每个 Tab 直接打开 data 目录下对应配置文件,用系统默认编辑器快速配置,每个 Tab 只管理自己栏目的配置。 +design: + styleKeywords: + - Clean + - Minimalist + fontSystem: + fontFamily: Microsoft YaHei + heading: + size: 16px + weight: 700 + subheading: + size: 14px + weight: 600 + body: + size: 13px + weight: 400 + colorSystem: + primary: + - "#4CAF50" + background: + - "#F5F5F5" + - "#FFFFFF" + text: + - "#333333" + - "#666666" + functional: + - "#FF9800" + - "#F44336" +todos: + - id: refactor-external-config + content: 移除 ExternalConfigManager 中 MyBatis 相关配置节,只保留系统路径、数据文件路径和字体大小 + status: completed + - id: refactor-path-tab + content: 重构系统路径 Tab,移除表单,改为显示 workutils.config 路径 + 打开按钮 + 重新加载按钮 + status: completed + dependencies: + - refactor-external-config + - id: refactor-mybatis-tab + content: 重构 MyBatis 数据库 Tab,移除表单,改为显示 mybatis-generator.properties 路径 + 打开按钮 + 重新加载按钮 + status: completed + dependencies: + - refactor-external-config + - id: refactor-project-tab + content: 重构项目路径 Tab,移除 TextArea,改为显示 project_paths.txt 路径 + 打开按钮,保留批量替换工具 + status: completed + - id: add-datasource-tab + content: 新增多数据源 Tab,显示 datasources.json 路径 + 打开按钮 + 重新加载按钮 + status: completed + - id: refactor-damaged-cache-tab + content: 重构损坏缓存 Tab,移除 TextArea,改为显示 damaged_repos.cache 路径 + 打开按钮 + 清理缓存按钮 + status: completed + - id: add-open-file-util + content: 新增 openFileInEditor 工具方法,支持 Desktop.edit 和 notepad fallback + status: completed + - id: compile-test + content: 编译验证并测试所有 Tab 功能 + status: completed + dependencies: + - refactor-path-tab + - refactor-mybatis-tab + - refactor-project-tab + - add-datasource-tab + - refactor-damaged-cache-tab + - add-open-file-util +--- + +## 产品概述 + +重构系统设置窗口,去掉繁琐的 JavaFX 表单填写体验,改为每个 Tab 直接打开 data 目录下对应的配置文件,用系统默认编辑器(记事本/IDE)快速配置。每个 Tab 只管理自己栏目的配置,各配置文件独立管理。 + +## 核心功能 + +- **系统路径 Tab**:显示 `data/workutils.config` 文件路径,提供「用记事本打开」按钮直接编辑系统路径和字体设置 +- **MyBatis 数据库 Tab**:显示 `data/mybatis-generator.properties` 文件路径,提供「用记事本打开」按钮直接编辑数据库连接和包路径配置 +- **项目路径 Tab**:显示 `data/project_paths.txt` 文件路径,提供「用记事本打开」按钮直接编辑项目路径列表 +- **多数据源 Tab**:显示 `data/datasources.json` 文件路径,提供「用记事本打开」按钮直接编辑多数据源配置 +- **损坏缓存 Tab**:显示 `data/damaged_repos.cache` 文件路径,标记为只读,提供「用记事本打开」按钮查看,保留「清理缓存」按钮 +- **重新加载**:每个 Tab 提供「重新加载」按钮,让程序重新读取文件使配置生效 +- **ExternalConfigManager 精简**:移除 `SECTIONS` 中的 MyBatis 相关配置节,只保留系统路径、数据文件路径和字体大小 + +## 技术栈 + +- JavaFX(原有技术栈,保持一致的 UI 风格) +- Desktop.getDesktop().edit() 打开配置文件(Windows 下用默认编辑器打开) + +## 实施方案 + +### 架构设计 + +重构后的设置窗口每个 Tab 统一结构: + +``` +┌─────────────────────────────────────────────────────────┐ +│ Tab 标题(如:系统路径) │ +│ │ +│ 配置文件: C:\xxx\data\workutils.config │ +│ │ +│ [用记事本打开] [重新加载] │ +│ │ +│ 说明文字:编辑后点击「重新加载」生效,无需重启。 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 关键修改点 + +#### 1. SettingsWindow.java — 重构各 Tab + +**createPathTab()**(系统路径 Tab): + +- 移除所有 GridPane 表单行(tfBaseDir、tfExportDir 等 TextField) +- 显示 `properties.getWorkutilsConfigFilePath()` 绝对路径 +- 保留「用记事本打开」按钮(调用 `Desktop.getDesktop().edit(file)`) +- 保留「重新加载」按钮(调用 `properties.mergeExternal(configManager.load())`) +- 保留字体大小设置说明(字体设置仍在 workutils.config 中) + +**createMyBatisTab()**(MyBatis 数据库 Tab): + +- 完全移除 GridPane 表单(tfDbDriver、tfDbUrl 等) +- 显示 `properties.getMybatisGeneratorFilePath()` 绝对路径 +- 「用记事本打开」按钮打开 `data/mybatis-generator.properties` +- 「重新加载」按钮调用已有逻辑重新加载 MyBatis 配置 +- 移除「从内置配置加载」按钮(用户直接编辑文件即可) + +**createProjectTab()**(项目路径 Tab): + +- 移除 TextArea + 加载/保存按钮的复杂结构 +- 显示 `properties.getProjectPathsFilePath()` 绝对路径 +- 「用记事本打开」按钮打开 `data/project_paths.txt` +- 保留「重新加载」按钮刷新项目路径列表 +- **保留批量替换工具**(这是用户之前明确需求的功能,在 memory ID 70362697 中有记录) + +**createDatasourceTab()**(新增 Tab — 多数据源): + +- 显示 `properties.getDatasourcesFilePath()` 绝对路径 +- 「用记事本打开」按钮打开 `data/datasources.json` +- 「重新加载」按钮调用 `datasourceConfigManager.reload()` + +**createDamagedCacheTab()**(损坏缓存 Tab,替代原项目管理 Tab 中的子 Tab): + +- 显示 `properties.getDamagedReposFilePath()` 绝对路径 +- 标记为只读(加提示文字) +- 「用记事本打开」按钮打开 `data/damaged_repos.cache` +- 保留「清理缓存」按钮 + +**createDisplayTab()**(显示设置 Tab): + +- 字体设置仍在 `workutils.config` 中,所以此 Tab 内容与系统路径 Tab 操作的是同一个文件 +- 可以简化为一个提示:「字体设置请在 workutils.config 中修改 font.size.* 属性」+ 「重新加载」按钮 +- 或者保留现有的滑块预览(用户可能喜欢实时预览字体效果),但保存时改为提示用户去文件编辑 + +**建议**:显示设置 Tab 保留现有滑块预览功能(用户体验好),但「保存」按钮改为提示用户手动编辑 workutils.config,或仍通过 `ExternalConfigManager.saveSectioned()` 保存字体设置(因为字体设置是系统级的,适合集中管理)。 + +#### 2. ExternalConfigManager.java — 移除 MyBatis 配置节 + +修改 `SECTIONS` 静态初始化块,移除: + +- `"===== MyBatis 数据库连接(覆盖内置 mybatis-generator.properties)====="` +- `"===== MyBatis 代码生成包路径 ====="` + +只保留: + +- `"===== 系统路径 ====="` +- `"===== 数据文件路径 ====="` +- `"===== 字体大小(显示设置) ====="` + +同步移除对应的 KEY 常量(或修改注释说明这些 KEY 仅用于 workutils.config)。 + +#### 3. 打开文件工具方法 + +在 SettingsWindow 中新增工具方法: + +```java +private void openFileInEditor(Path file) { + try { + if (!Files.exists(file)) { + Files.createDirectories(file.getParent()); + Files.createFile(file); + } + // Windows: Desktop.edit 用关联程序打开并允许编辑 + // 如果 Desktop 不可用,fallback 到 Runtime.exec("notepad") + if (Desktop.isDesktopSupported()) { + Desktop.getDesktop().edit(file.toFile()); + } else { + // Windows fallback + Runtime.getRuntime().exec(new String[]{"notepad", file.toAbsolutePath().toString()}); + } + } catch (Exception e) { + showAlert(AlertType.ERROR, "打开文件失败: " + e.getMessage()); + } +} +``` + +### 实施注意事项 + +1. **Desktop.getDesktop().edit()** 在 Windows 上需要文件有关联的编辑器,且文件必须存在 +2. **批量替换工具保留**:用户的 memory 中明确提到需要保留项目路径批量替换功能,不能删除 +3. **向后兼容**:workutils.config 中如果仍有旧的 MyBatis 配置项,程序应能忽略(不报错) +4. **文件不存在处理**:打开前检查文件是否存在,不存在则创建空文件或从模板复制 + +### 目录结构变更 + +``` +src/main/java/com/example/workutils/system/config/ +├── SettingsWindow.java [MODIFY] 重构所有 Tab 内容 +├── ExternalConfigManager.java [MODIFY] 移除 MyBatis 配置节 +├── WorkUtilsProperties.java [NO CHANGE] 无需修改 +├── DatasourceConfigManager.java [NO CHANGE] 无需修改 +└── ConfigInitializer.java [NO CHANGE] 无需修改 +``` + +无需新增文件,只需修改现有两个文件。 + +## 设计风格 + +保持现有 JavaFX 窗口的简洁风格,每个 Tab 内容统一为: + +- 顶部:标题(加粗,字号稍大) +- 中部:配置文件路径(等宽字体,可选择性可复制) +- 按钮区:[]用记事本打开] []重新加载](对齐,间距合理) +- 底部:说明文字(灰色,小字号) + +## 布局说明 + +每个 Tab 使用 VBox 垂直布局,内容居中偏左对齐,整体留白适中(Insets 12px)。 +按钮使用 HBox 水平排列,间距 10px。 +路径 Label 使用等宽字体(Consolas),背景浅灰,增加可读性。 \ No newline at end of file diff --git a/scripts/fix_urls.java b/scripts/fix_urls.java new file mode 100644 index 0000000..b55b067 --- /dev/null +++ b/scripts/fix_urls.java @@ -0,0 +1,27 @@ +import java.nio.file.*; +import java.util.*; + +public class fix_urls { + public static void main(String[] args) throws Exception { + // Fix both template and data files + String[] files = {"data/project_paths.txt", "src/main/resources/templates/project_paths.txt"}; + for (String file : files) { + Path p = Paths.get(file); + List lines = Files.readAllLines(p, java.nio.charset.StandardCharsets.UTF_8); + int fixed = 0; + for (int i = 0; i < lines.size(); i++) { + String l = lines.get(i); + if (l.contains("=") && l.endsWith("cf/")) { + int eq = l.lastIndexOf('='); + String path = l.substring(0, eq).trim(); + String url = l.substring(eq + 1).trim(); + String proj = path.substring(path.lastIndexOf('/') + 1); + lines.set(i, path + "=" + url + proj + ".git"); + fixed++; + } + } + Files.write(p, lines, java.nio.charset.StandardCharsets.UTF_8); + System.out.println(file + ": fixed " + fixed + " lines, total " + lines.size()); + } + } +} diff --git a/src/main/java/com/example/workutils/WorkUtilsApplication.java b/src/main/java/com/example/workutils/WorkUtilsApplication.java index 5900f8d..df0580b 100644 --- a/src/main/java/com/example/workutils/WorkUtilsApplication.java +++ b/src/main/java/com/example/workutils/WorkUtilsApplication.java @@ -1,26 +1,43 @@ package com.example.workutils; +import com.example.workutils.system.config.*; import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.PluginDescriptor; import com.example.workutils.system.plugin.PluginManager; +import com.example.workutils.system.plugin.VisualPluginFactory; +import com.example.workutils.system.support.JfxBootstrap; import com.example.workutils.system.support.WinConsole; +import com.example.workutils.system.ui.MainWindow; +import javafx.application.Platform; +import javafx.scene.Parent; +import javafx.scene.control.Alert; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; -import java.util.Arrays; -import java.util.Optional; -import java.util.Scanner; +import java.util.*; +import java.util.concurrent.CountDownLatch; @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) @Slf4j public class WorkUtilsApplication implements CommandLineRunner { private final PluginManager pluginManager; + private final WorkUtilsProperties properties; + private final SettingsService settingsService; - public WorkUtilsApplication(PluginManager pluginManager) { + /** 主窗口引用,供内嵌插件面板使用 */ + private MainWindow mainWindow; + /** 系统设置窗口缓存(内嵌模式复用) */ + private SettingsWindow settingsWindow; + + public WorkUtilsApplication(PluginManager pluginManager, WorkUtilsProperties properties, + SettingsService settingsService) { this.pluginManager = pluginManager; + this.properties = properties; + this.settingsService = settingsService; } public static void main(String[] args) { @@ -31,29 +48,188 @@ public class WorkUtilsApplication implements CommandLineRunner { @Override public void run(String... args) throws Exception { if (args.length == 0) { + showMainWindow(); + } else if ("--console".equals(args[0])) { showInteractiveMenu(); } else { processCommand(args); } } + // ==================== 主窗口入口 ==================== + + private void showMainWindow() throws InterruptedException { + JfxBootstrap.await(); + if (JfxBootstrap.isFailed()) { + System.err.println("JavaFX 初始化失败,回退到控制台模式"); + showInteractiveMenu(); + return; + } + + List navItems = buildNavItems(); + + CountDownLatch windowClosed = new CountDownLatch(1); + Platform.runLater(() -> { + try { + mainWindow = new MainWindow(navItems, properties); + mainWindow.showAndWait(); + } catch (Exception e) { + log.error("主窗口创建失败", e); + System.err.println("主窗口创建失败: " + e.getMessage()); + } finally { + windowClosed.countDown(); + } + }); + + windowClosed.await(); + log.info("主窗口已关闭"); + } + + // ==================== 导航项构建 ==================== + + private List buildNavItems() { + List items = new ArrayList<>(); + + for (Plugin plugin : pluginManager.getEnabledPlugins()) { + PluginDescriptor desc = plugin.getDescriptor(); + VisualPluginFactory factory = plugin.getVisualFactory(); + String pluginId = desc.getId(); + + items.add(new MainWindow.NavItem( + desc.getIcon(), + desc.getName(), + desc.getDescription(), + factory != null ? () -> openPluginEmbedded(pluginId, plugin, factory) : null, + pluginId + )); + } + + // 系统设置 — 内嵌为 Tab + items.add(new MainWindow.NavItem( + "⚙️", "系统设置", + "配置系统路径、数据库连接、项目路径、多数据源、字体大小等", + this::openSettingsEmbedded, + "__settings__" + )); + + return items; + } + + // ==================== 内嵌优先打开 ==================== + + /** + * 优先尝试内嵌模式;若插件不支持嵌入,则退回到独立窗口。 + *

+ * 在 JFX 线程上调用(来自导航按钮事件)。 + */ + private void openPluginEmbedded(String pluginId, Plugin plugin, VisualPluginFactory factory) { + // 配置检查 + String configError = plugin.checkConfig(); + if (configError != null) { + showConfigAlert(plugin.getName(), configError); + return; + } + + // 尝试获取内嵌面板 + Parent embeddedPane = factory.getEmbeddedPane(); + if (embeddedPane != null && mainWindow != null) { + PluginDescriptor desc = plugin.getDescriptor(); + mainWindow.embedPlugin(pluginId, desc.getIcon(), desc.getName(), embeddedPane); + return; + } + + // 回退:独立窗口模式 + openPluginStandalone(plugin, factory); + } + + // ==================== 独立窗口模式(弹出或回退) ==================== + + /** + * 以独立窗口形式打开插件。用于: + *

    + *
  • 插件不支持内嵌时的回退
  • + *
  • 用户点击「弹出窗口」按钮
  • + *
+ */ + private void openPluginStandalone(Plugin plugin, VisualPluginFactory factory) { + new Thread(() -> { + try { + JfxBootstrap.await(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + + CountDownLatch windowClosed = new CountDownLatch(1); + Platform.runLater(() -> { + try { + factory.open(windowClosed::countDown); + } catch (Exception e) { + log.error("独立窗口创建失败: {}", plugin.getName(), e); + windowClosed.countDown(); + } + }); + try { + windowClosed.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }, plugin.getId() + "-Window").start(); + } + + // ==================== 配置提示 ==================== + + private void showConfigAlert(String pluginName, String message) { + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("配置检查"); + alert.setHeaderText(pluginName + " 配置不完整"); + alert.setContentText(message); + alert.showAndWait(); + } + + // ==================== 系统设置 ==================== + + private void openSettingsEmbedded() { + if (settingsWindow == null) { + settingsWindow = settingsService.createSettingsWindow(null); + } + if (mainWindow != null) { + Parent pane = settingsWindow.getContentPane(); + mainWindow.embedPlugin("__settings__", "⚙️", "系统设置", pane); + } + } + + private void openSettingsStandalone() { + if (settingsWindow == null) { + settingsWindow = settingsService.createSettingsWindow(null); + } + javafx.stage.Stage s = settingsWindow.getStage(); + if (s != null) { + s.show(); + } + } + + /** @deprecated 保留兼容旧路径 */ + private void openSettingsWindow() { + new Thread(() -> settingsService.showSettings(null, null), + "SettingsWindow-Thread").start(); + } + + // ==================== 控制台模式(降级) ==================== + private void showInteractiveMenu() { Scanner scanner = new Scanner(System.in); pluginManager.runMainMenu(scanner); } - /** - * 命令行模式:通过 PluginManager 按命令名分发到对应 Tool - * 启动类不依赖任何具体业务 Service - */ private void processCommand(String... args) throws Exception { String command = args[0].toLowerCase(); - Optional toolOpt = pluginManager.getPluginByCommand(command); if (toolOpt.isPresent()) { Plugin plugin = toolOpt.get(); try { - String[] toolArgs = args.length > 1 ? Arrays.copyOfRange(args, 1, args.length) : new String[0]; + String[] toolArgs = args.length > 1 + ? Arrays.copyOfRange(args, 1, args.length) : new String[0]; plugin.executeCommand(toolArgs); } catch (Exception e) { log.error("命令执行失败: {} [{}]", plugin.getName(), plugin.getId(), e); diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java index 966ec4a..e3c3040 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java @@ -6,7 +6,10 @@ import com.example.workutils.biz.apidoc.parser.JavaSourceParser; import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.plugin.Plugin; import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; import com.example.workutils.system.support.FileOperationHelper; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -16,6 +19,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.*; +import java.util.concurrent.CountDownLatch; import java.util.stream.Stream; /** @@ -35,6 +39,7 @@ public class ApiDocPlugin implements Plugin { .version("1.0.0") .description("基于 Java 源码静态分析,导出 Dubbo/Spring 接口文档 (HTML/Markdown)") .order(50) + .icon("📄") .build(); private static final String[] MENU_OPTIONS = { @@ -53,6 +58,42 @@ public class ApiDocPlugin implements Plugin { @Override public String[] getMenuOptions() { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private ApiDocWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new ApiDocWindow(fileOperationHelper, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new ApiDocWindow(fileOperationHelper, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "apidoc-close").start(); + } + }; + } + @Override public String checkConfig() { Path ppf = properties.getProjectPathsFilePath(); diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java new file mode 100644 index 0000000..74fee4b --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java @@ -0,0 +1,579 @@ +package com.example.workutils.biz.apidoc; + +import com.example.workutils.biz.apidoc.generator.ApiDocGenerator; +import com.example.workutils.biz.apidoc.model.ApiDocModel; +import com.example.workutils.biz.apidoc.parser.JavaSourceParser; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.scene.Parent; +import javafx.stage.DirectoryChooser; +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.stream.Stream; + +/** + * API 接口文档导出可视化窗口 + *

+ * 支持三种模式:自定义路径、全限定名、批量项目导出 + */ +public class ApiDocWindow { + + private final Stage stage; + private final CountDownLatch closeLatch; + private final WorkUtilsProperties properties; + private final FileOperationHelper fileOperationHelper; + + // 模式选择 + private ToggleGroup modeGroup; + private RadioButton rbCustomPath, rbFqn, rbBatch; + + // 自定义路径 + private TextField pathField; + private Button browseFileBtn, browseDirBtn; + + // 全限定名 + private TextField fqnField; + + // 批量 + private ComboBox projectCmb; + private CheckBox allProjectsCb; + + // 导出格式 + private ToggleGroup fmtGroup; + private RadioButton rbHtml, rbMd, rbBoth; + + // 操作区 + private Button exportBtn; + private ProgressBar progressBar; + private Label progressLabel; + private TextArea logArea; + + public ApiDocWindow(FileOperationHelper fileOperationHelper, WorkUtilsProperties properties, + CountDownLatch closeLatch) { + this.fileOperationHelper = fileOperationHelper; + this.properties = properties; + this.closeLatch = closeLatch; + this.stage = new Stage(); + buildUI(); + loadProjects(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { return stage; } + + private int fsSection() { return properties.getFontSectionTitle(); } + private int fsBtn() { return properties.getFontButton(); } + private int fsBody() { return properties.getFontBody(); } + + private void buildUI() { + stage.setTitle("API 接口文档导出"); + stage.setWidth(800); + stage.setHeight(600); + stage.setMinWidth(650); + stage.setMinHeight(450); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + VBox root = new VBox(Theme.S3); + root.setPadding(new Insets(Theme.S4)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + // === 模式选择 === + VBox modeBox = new VBox(Theme.S2); + modeBox.setPadding(new Insets(Theme.S4)); + modeBox.setStyle(Theme.STYLE_CARD); + + Label modeLabel = new Label("导出模式"); + modeLabel.setFont(Theme.heading(fsSection() + 2)); + modeLabel.setTextFill(Theme.TEXT_PRIMARY); + + modeGroup = new ToggleGroup(); + HBox modeRow = new HBox(Theme.S5); + rbCustomPath = new RadioButton("自定义路径"); + rbFqn = new RadioButton("全限定名"); + rbBatch = new RadioButton("批量导出"); + rbCustomPath.setToggleGroup(modeGroup); + rbFqn.setToggleGroup(modeGroup); + rbBatch.setToggleGroup(modeGroup); + rbCustomPath.setSelected(true); + rbCustomPath.setFont(Theme.base(fsBody())); + rbFqn.setFont(Theme.base(fsBody())); + rbBatch.setFont(Theme.base(fsBody())); + modeRow.getChildren().addAll(rbCustomPath, rbFqn, rbBatch); + + // 输入区域(StackPane 切换) + StackPane inputStack = new StackPane(); + inputStack.setPrefHeight(100); + + // 自定义路径面板 + VBox customPanel = new VBox(Theme.S2); + pathField = new TextField(); + pathField.setFont(Theme.base(fsBody())); + pathField.setStyle(Theme.STYLE_TEXT_FIELD); + pathField.setPrefWidth(500); + pathField.setPromptText("输入文件路径或目录路径,或点击右侧按钮选择"); + HBox btnRow = new HBox(Theme.S2); + browseFileBtn = StyledButton.small("选择文件", Theme.base(fsBody() - 1)); + browseDirBtn = StyledButton.small("选择目录", Theme.base(fsBody() - 1)); + browseFileBtn.setOnAction(e -> browseFile()); + browseDirBtn.setOnAction(e -> browseDir()); + btnRow.getChildren().addAll(browseFileBtn, browseDirBtn); + customPanel.getChildren().addAll(pathField, btnRow); + + // 全限定名面板 + VBox fqnPanel = new VBox(Theme.S2); + fqnField = new TextField(); + fqnField.setFont(Theme.base(fsBody())); + fqnField.setStyle(Theme.STYLE_TEXT_FIELD); + fqnField.setPrefWidth(500); + fqnField.setPromptText("例如: com.example.service.UserFacade"); + fqnPanel.getChildren().addAll(new Label("全限定类名:"), fqnField); + + // 批量面板 + VBox batchPanel = new VBox(Theme.S2); + projectCmb = new ComboBox<>(); + projectCmb.setStyle(Theme.STYLE_COMBO + "-fx-font-family: 'Microsoft YaHei'; -fx-font-size: " + fsBody() + "px;"); + projectCmb.setPrefWidth(Math.max(400, fsBody() * 28)); // 动态宽度,保证长路径完整显示 + projectCmb.setPromptText("选择项目"); + allProjectsCb = new CheckBox("全部项目"); + allProjectsCb.setFont(Theme.base(fsBody() - 1)); + batchPanel.getChildren().addAll(projectCmb, allProjectsCb); + + inputStack.getChildren().addAll(customPanel, fqnPanel, batchPanel); + + // 切换可见性 + customPanel.visibleProperty().bind(rbCustomPath.selectedProperty()); + fqnPanel.visibleProperty().bind(rbFqn.selectedProperty()); + batchPanel.visibleProperty().bind(rbBatch.selectedProperty()); + + modeBox.getChildren().addAll(modeLabel, modeRow, inputStack); + + // === 导出格式 === + HBox fmtRow = new HBox(Theme.S5); + fmtRow.setAlignment(Pos.CENTER_LEFT); + Label fmtLabel = new Label("导出格式:"); + fmtLabel.setFont(Theme.base(fsBody())); + fmtLabel.setTextFill(Theme.TEXT_PRIMARY); + fmtGroup = new ToggleGroup(); + rbHtml = new RadioButton("HTML"); + rbMd = new RadioButton("Markdown"); + rbBoth = new RadioButton("两者都导出"); + rbHtml.setToggleGroup(fmtGroup); + rbMd.setToggleGroup(fmtGroup); + rbBoth.setToggleGroup(fmtGroup); + rbBoth.setSelected(true); + rbHtml.setFont(Theme.base(fsBody() - 1)); + rbMd.setFont(Theme.base(fsBody() - 1)); + rbBoth.setFont(Theme.base(fsBody() - 1)); + fmtRow.getChildren().addAll(fmtLabel, rbHtml, rbMd, rbBoth); + + // === 操作按钮 === + HBox actionRow = new HBox(Theme.S3); + actionRow.setAlignment(Pos.CENTER_LEFT); + exportBtn = StyledButton.primary("导出文档", Theme.bold(fsBtn())); + exportBtn.setOnAction(e -> startExport()); + + progressBar = new ProgressBar(0); + progressBar.setPrefWidth(300); + progressBar.setPrefHeight(16); + progressBar.setVisible(false); + + progressLabel = new Label(""); + progressLabel.setFont(Theme.base(fsBody() - 2)); + progressLabel.setTextFill(Theme.TEXT_SECONDARY); + + actionRow.getChildren().addAll(exportBtn, progressBar, progressLabel); + + // === 日志 === + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setFont(Theme.mono(properties.getFontMono())); + logArea.setStyle(Theme.STYLE_LOG_AREA); + VBox.setVgrow(logArea, Priority.ALWAYS); + + root.getChildren().addAll(modeBox, fmtRow, actionRow, logArea); + stage.setScene(new Scene(root)); + } + + private void browseFile() { + FileChooser fc = new FileChooser(); + fc.getExtensionFilters().add(new FileChooser.ExtensionFilter("Java 文件", "*.java")); + fc.setTitle("选择 Java 源文件"); + File f = fc.showOpenDialog(stage); + if (f != null) pathField.setText(f.getAbsolutePath()); + } + + private void browseDir() { + DirectoryChooser dc = new DirectoryChooser(); + dc.setTitle("选择目录"); + File f = dc.showDialog(stage); + if (f != null) pathField.setText(f.getAbsolutePath()); + } + + private void loadProjects() { + try { + List paths = fileOperationHelper.getProjectPaths(); + projectCmb.getItems().clear(); + for (String p : paths) { + projectCmb.getItems().add(p); + } + if (!paths.isEmpty()) projectCmb.setValue(paths.get(0)); + } catch (IOException e) { + logArea.appendText("[WARN] 无法加载项目列表: " + e.getMessage() + "\n"); + } + } + + private void startExport() { + new Thread(() -> { + try { + if (rbCustomPath.isSelected()) { + String path = pathField.getText().trim(); + if (path.isEmpty()) { + log("请输入路径"); + return; + } + Path tp = Paths.get(path); + JavaSourceParser parser = new JavaSourceParser(); + if (parser.isJavaFile(tp)) { + exportSingle(path, tp, parser); + } else if (parser.isDirectory(tp)) { + exportDir(path, tp, parser); + } else { + log("路径不存在或无效: " + path); + } + } else if (rbFqn.isSelected()) { + exportByFqn(); + } else { + exportBatch(); + } + } catch (Exception e) { + Platform.runLater(() -> { + progressBar.setVisible(false); + log("[ERROR] " + e.getMessage()); + exportBtn.setDisable(false); + }); + } + }, "ApiDoc-Worker").start(); + } + + private String getFmt() { + if (rbHtml.isSelected()) return "1"; + if (rbMd.isSelected()) return "2"; + return "3"; + } + + // ==================== 单文件导出 ==================== + + private void exportSingle(String inputPath, Path filePath, JavaSourceParser parser) throws Exception { + String projName = extractProjName(inputPath); + log("文件: " + filePath.getFileName() + " 项目: " + projName); + + ApiDocModel.ClassInfo ci = parser.parseFile(filePath, projName, true); + if (ci == null || ci.methods == null || ci.methods.isEmpty()) { + log("[WARN] 未找到有效的类/接口或公开方法"); + enableBtn(); + return; + } + log("解析: " + ci.type + " " + ci.className + ", " + ci.methods.size() + " 个方法"); + + Path srcBase = resolveSrcBase(inputPath); + if (srcBase != null) { + log("正在解析 DTO..."); + parser.resolveDtosForClass(ci, srcBase); + } + + ci.projectName = projName; + ApiDocModel.ProjectApiDoc doc = new ApiDocModel.ProjectApiDoc(); + doc.projectName = projName; doc.projectPath = inputPath; + doc.classes = Collections.singletonList(ci); + + doExport(projName, Collections.singletonList(doc)); + } + + // ==================== 目录导出 ==================== + + private void exportDir(String inputPath, Path dirPath, JavaSourceParser parser) throws Exception { + String projName = extractProjName(inputPath); + log("目录: " + dirPath + " 项目: " + projName); + + log("正在扫描 .java 文件..."); + List javaFiles = parser.findJavaFilesFrom(dirPath); + log("找到 " + javaFiles.size() + " 个文件,正在解析..."); + + List classes = new ArrayList<>(); + for (Path f : javaFiles) { + ApiDocModel.ClassInfo ci = parser.parseFile(f, projName, true); + if (ci != null && ci.methods != null && !ci.methods.isEmpty()) { + classes.add(ci); + } + } + classes.sort(Comparator.comparing(c -> c.className)); + int methods = classes.stream().mapToInt(c -> c.methods.size()).sum(); + log("解析完成: " + classes.size() + " 个类, " + methods + " 个方法"); + + ApiDocModel.ProjectApiDoc doc = new ApiDocModel.ProjectApiDoc(); + doc.projectName = projName; doc.projectPath = inputPath; + doc.classes = classes; + + doExport(projName, Collections.singletonList(doc)); + } + + // ==================== 全限定名导出 ==================== + + private void exportByFqn() throws Exception { + String fqn = fqnField.getText().trim(); + if (fqn.isEmpty()) { log("请输入全限定类名"); return; } + + List projectPaths = fileOperationHelper.getProjectPaths(); + if (projectPaths.isEmpty()) { log("[WARN] 未配置项目路径"); return; } + + String relativePath = fqn.replace('.', '/') + ".java"; + Path foundFile = null; + Path foundSrcBase = null; + String foundProjName = null; + + for (String projPath : projectPaths) { + Path projDir = Paths.get(projPath); + if (!Files.isDirectory(projDir)) continue; + List srcBases = new ArrayList<>(); + Path selfSrc = projDir.resolve("src/main/java"); + if (Files.isDirectory(selfSrc)) srcBases.add(selfSrc); + try (Stream children = Files.list(projDir)) { + children.filter(Files::isDirectory) + .filter(d -> !d.getFileName().toString().startsWith(".")) + .forEach(d -> collectSrcBases(d, srcBases)); + } catch (Exception ignored) {} + + for (Path srcBase : srcBases) { + Path candidate = srcBase.resolve(relativePath); + if (Files.isRegularFile(candidate)) { + foundFile = candidate; + foundSrcBase = srcBase; + foundProjName = extractProjName(candidate.toString().replace('\\', '/')); + break; + } + } + if (foundFile != null) break; + } + + if (foundFile == null) { log("[ERROR] 在所有项目中未找到 " + fqn); return; } + + log("找到: " + foundFile + " 项目: " + foundProjName); + + JavaSourceParser parser = new JavaSourceParser(); + ApiDocModel.ClassInfo ci = parser.parseFile(foundFile, foundProjName, true); + if (ci == null || ci.methods == null || ci.methods.isEmpty()) { + log("[WARN] 未找到有效方法"); + enableBtn(); + return; + } + log("解析: " + ci.className + ", " + ci.methods.size() + " 个方法"); + + if (foundSrcBase != null) { + log("正在解析 DTO..."); + parser.resolveDtosForClass(ci, foundSrcBase); + } + + ci.projectName = foundProjName; + ApiDocModel.ProjectApiDoc doc = new ApiDocModel.ProjectApiDoc(); + doc.projectName = foundProjName; doc.projectPath = foundFile.toString(); + doc.classes = Collections.singletonList(ci); + + doExport(foundProjName, Collections.singletonList(doc)); + } + + // ==================== 批量导出 ==================== + + private void exportBatch() throws Exception { + List selectedPaths = new ArrayList<>(); + if (allProjectsCb.isSelected()) { + selectedPaths.addAll(fileOperationHelper.getProjectPaths()); + } else { + String sel = projectCmb.getValue(); + if (sel == null || sel.isEmpty()) { log("请选择项目"); return; } + selectedPaths.add(sel); + } + + log("开始批量导出,共 " + selectedPaths.size() + " 个项目"); + + Set seenSrcBases = new HashSet<>(); + List modules = new ArrayList<>(); + for (String projPath : selectedPaths) { + Path projDir = Paths.get(projPath); + if (!Files.isDirectory(projDir)) continue; + Path selfSrc = projDir.resolve("src/main/java"); + String key = selfSrc.toString().replace('\\', '/'); + if (Files.isDirectory(selfSrc) && seenSrcBases.add(key)) { + modules.add(new ModuleInfo(projDir.getFileName().toString(), projPath, selfSrc)); + } + try (Stream children = Files.list(projDir)) { + children.filter(Files::isDirectory).filter(d -> !d.getFileName().toString().startsWith(".")) + .forEach(d -> collectModules(d, modules, seenSrcBases)); + } catch (Exception ignored) {} + } + + if (modules.isEmpty()) { log("[WARN] 未找到包含 src/main/java 的模块"); return; } + log("找到 " + modules.size() + " 个模块"); + + List allDocs = new ArrayList<>(); + int totalClasses = 0, totalMethods = 0; + + for (ModuleInfo m : modules) { + log("[" + m.name + "] 扫描..."); + JavaSourceParser parser = new JavaSourceParser(); + List javaFiles = parser.findJavaFilesFrom(m.srcBase); + if (javaFiles.isEmpty()) continue; + + List classes = new ArrayList<>(); + for (Path f : javaFiles) { + ApiDocModel.ClassInfo ci = parser.parseFile(f, m.name, true); + if (ci != null && ci.methods != null && !ci.methods.isEmpty()) { + ci.projectName = m.name; + classes.add(ci); + } + } + if (classes.isEmpty()) continue; + classes.sort(Comparator.comparing(c -> c.className)); + + // DTO + JavaSourceParser dtoParser = new JavaSourceParser(); + for (ApiDocModel.ClassInfo ci : classes) dtoParser.resolveDtosForClass(ci, m.srcBase); + + ApiDocModel.ProjectApiDoc doc = new ApiDocModel.ProjectApiDoc(); + doc.projectName = m.name; doc.projectPath = m.path; + doc.classes = classes; + allDocs.add(doc); + + totalClasses += classes.size(); + totalMethods += classes.stream().mapToInt(c -> c.methods.size()).sum(); + log(" " + classes.size() + " 个类, " + totalMethods + " 个方法 (累计)"); + } + + log("总计: " + allDocs.size() + " 个模块, " + totalClasses + " 个类"); + doExport("批量导出", allDocs); + } + + // ==================== 导出输出 ==================== + + private void doExport(String name, List docs) throws Exception { + ApiDocGenerator gen = new ApiDocGenerator(properties); + ApiDocModel.ExportSelection sel = new ApiDocModel.ExportSelection(); + sel.scope = ApiDocModel.ExportScope.CUSTOM; + sel.projectName = name; + + String fmt = getFmt(); + if ("1".equals(fmt)) { + Path p = gen.generateHtml(sel, docs); + Platform.runLater(() -> { + log("HTML 已导出: " + p.toAbsolutePath()); + enableBtn(); + }); + } else if ("2".equals(fmt)) { + Path p = gen.generateMarkdown(sel, docs); + Platform.runLater(() -> { + log("Markdown 已导出: " + p.toAbsolutePath()); + enableBtn(); + }); + } else { + Path html = gen.generateHtml(sel, docs); + Path md = gen.generateMarkdown(sel, docs); + Platform.runLater(() -> { + log("HTML: " + html.toAbsolutePath()); + log("Markdown: " + md.toAbsolutePath()); + enableBtn(); + }); + } + } + + private void log(String msg) { + Platform.runLater(() -> logArea.appendText(msg + "\n")); + } + + private void enableBtn() { + Platform.runLater(() -> { + progressBar.setVisible(false); + exportBtn.setDisable(false); + }); + } + + // ==================== 工具方法(移植自 ApiDocPlugin) ==================== + + private String extractProjName(String path) { + if (path == null || path.isEmpty()) return "unknown"; + String n = path.replace('\\', '/'); + int si = n.lastIndexOf("/src/main/java"); + if (si <= 0) si = n.lastIndexOf("/src/"); + if (si > 0) return lastSeg(n.substring(0, si)); + String name = lastSeg(n); + return name.endsWith(".java") ? name.substring(0, name.length() - 5) : name; + } + + private String lastSeg(String path) { + int i = path.lastIndexOf('/'); + return i >= 0 ? path.substring(i + 1) : path; + } + + private Path resolveSrcBase(String inputPath) { + if (inputPath == null || inputPath.isEmpty()) return null; + String n = inputPath.replace('\\', '/'); + int si = n.indexOf("/src/main/java"); + if (si > 0) return Paths.get(n.substring(0, si + "/src/main/java".length())); + si = n.indexOf("/src/"); + if (si > 0) return Paths.get(n.substring(0, si + "/src/main/java".length())); + return null; + } + + private void collectSrcBases(Path dir, List srcBases) { + if (!Files.isDirectory(dir)) return; + Path sb = dir.resolve("src/main/java"); + if (Files.isDirectory(sb)) srcBases.add(sb); + try (Stream children = Files.list(dir)) { + children.filter(Files::isDirectory).filter(d -> !d.getFileName().toString().startsWith(".")) + .forEach(d -> collectSrcBases(d, srcBases)); + } catch (Exception ignored) {} + } + + private void collectModules(Path dir, List modules, Set seen) { + if (!Files.isDirectory(dir)) return; + Path sb = dir.resolve("src/main/java"); + String key = sb.toString().replace('\\', '/'); + if (Files.isDirectory(sb) && seen.add(key)) { + modules.add(new ModuleInfo(dir.getFileName().toString(), + dir.toString().replace('\\', '/'), sb)); + } + try (Stream children = Files.list(dir)) { + children.filter(Files::isDirectory).filter(d -> !d.getFileName().toString().startsWith(".")) + .forEach(d -> collectModules(d, modules, seen)); + } catch (Exception ignored) {} + } + + private static class ModuleInfo { + final String name, path; + final Path srcBase; + ModuleInfo(String name, String path, Path srcBase) { + this.name = name; this.path = path; this.srcBase = srcBase; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java index e748cd3..652f56b 100644 --- a/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java +++ b/src/main/java/com/example/workutils/biz/diff/DiffPlugin.java @@ -3,13 +3,17 @@ 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 com.example.workutils.system.plugin.VisualPluginFactory; import com.example.workutils.system.support.JfxBootstrap; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.nio.file.Path; import java.util.*; +import java.util.concurrent.CountDownLatch; /** * 文本对比插件 @@ -32,6 +36,7 @@ public class DiffPlugin implements Plugin { .version("1.0.0") .description("基于LCS算法的文本差异对比工具,支持行级精确对比") .order(10) + .icon("📊") .build(); private static final String[] MENU_OPTIONS = { @@ -52,6 +57,42 @@ public class DiffPlugin implements Plugin { @Override public String[] getMenuOptions() { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private DiffWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new DiffWindow(DiffPlugin.this, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new DiffWindow(DiffPlugin.this, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "diff-close").start(); + } + }; + } + @Override public void handleChoice(int choice, Scanner scanner) throws Exception { switch (choice) { diff --git a/src/main/java/com/example/workutils/biz/diff/DiffWindow.java b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java index d9f15f1..a230257 100644 --- a/src/main/java/com/example/workutils/biz/diff/DiffWindow.java +++ b/src/main/java/com/example/workutils/biz/diff/DiffWindow.java @@ -1,6 +1,8 @@ package com.example.workutils.biz.diff; import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -17,6 +19,7 @@ import javafx.scene.paint.Color; import javafx.scene.text.Font; import javafx.scene.web.WebEngine; import javafx.scene.web.WebView; +import javafx.scene.Parent; import javafx.stage.DirectoryChooser; import javafx.stage.Screen; import javafx.stage.Stage; @@ -38,9 +41,8 @@ import java.util.concurrent.CountDownLatch; *

* 快捷键:Ctrl+Enter 对比,Ctrl+L 交换左右。 */ -class DiffWindow { +public class DiffWindow { - 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"); /** 字符级对比相似度阈值:相似度 >= 此值视为"修改同一行" */ @@ -70,7 +72,7 @@ class DiffWindow { this(plugin, null, closeLatch); } - DiffWindow(DiffPlugin plugin, WorkUtilsProperties properties, CountDownLatch closeLatch) { + public DiffWindow(DiffPlugin plugin, WorkUtilsProperties properties, CountDownLatch closeLatch) { this.plugin = plugin; this.properties = properties; this.closeLatch = closeLatch; @@ -102,11 +104,19 @@ class DiffWindow { // ==================== 窗口入口 ==================== - void show() { + public void show() { stage.show(); leftArea.requestFocus(); } + /** 返回内容根节点,供主窗口内嵌使用 */ + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + /** 返回所属 Stage,供弹出/停靠逻辑使用 */ + Stage getStage() { return stage; } + void waitForClose() { try { closeLatch.await(); @@ -123,23 +133,24 @@ class DiffWindow { // ---- 上半区:左右输入 ---- SplitPane inputSplit = new SplitPane(); + inputSplit.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); inputSplit.getItems().addAll(buildInputSide("原文", true), buildInputSide("对比文", false)); inputSplit.setDividerPositions(0.5); // ---- 操作栏 ---- - Button compareBtn = accentButton("▶ 开始对比"); - Button swapBtn = normalButton("⇄ 交换左右"); - Button clearAllBtn = normalButton("清空全部"); + Button compareBtn = StyledButton.primary("▶ 开始对比", Theme.bold(btnFontSize())); + Button swapBtn = StyledButton.secondary("⇄ 交换左右", Theme.base(btnFontSize())); + Button clearAllBtn = StyledButton.secondary("清空全部", Theme.base(btnFontSize())); compareBtn.setOnAction(e -> doCompare()); swapBtn.setOnAction(e -> swapText()); clearAllBtn.setOnAction(e -> clearAll()); - HBox actionBar = new HBox(10, clearAllBtn, compareBtn, swapBtn); + HBox actionBar = new HBox(Theme.S2, clearAllBtn, compareBtn, swapBtn); actionBar.setAlignment(Pos.CENTER); - actionBar.setPadding(new Insets(6, 0, 6, 0)); + actionBar.setPadding(new Insets(Theme.S2, 0, Theme.S2, 0)); - VBox topSection = new VBox(5, inputSplit, actionBar); + VBox topSection = new VBox(Theme.S1, inputSplit, actionBar); VBox.setVgrow(inputSplit, Priority.ALWAYS); // ---- 下半区:WebView 结果 ---- @@ -148,19 +159,22 @@ class DiffWindow { SplitPane mainSplit = new SplitPane(); mainSplit.setOrientation(javafx.geometry.Orientation.VERTICAL); mainSplit.getItems().addAll(topSection, resultSection); - mainSplit.setDividerPositions(0.38); + mainSplit.setDividerPositions(0.4); // ---- 状态栏 ---- - statusLabel = new Label("Ready — 粘贴文本到上方区域,Ctrl+Enter 开始对比"); - statusLabel.setFont(Font.font("System", bodyFontSize())); - statusLabel.setTextFill(Color.rgb(117, 117, 117)); - statusLabel.setPadding(new Insets(4, 8, 4, 8)); + statusLabel = new Label("Ctrl+Enter 开始对比 · Ctrl+L 交换左右 · 粘贴文本到上方区域"); + statusLabel.setFont(Theme.base(bodyFontSize())); + statusLabel.setTextFill(Theme.TEXT_SECONDARY); + statusLabel.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); statusLabel.setMaxWidth(Double.MAX_VALUE); + statusLabel.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";" + + "-fx-border-width: 1 0 0 0; -fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";"); BorderPane root = new BorderPane(); root.setCenter(mainSplit); root.setBottom(statusLabel); - root.setPadding(new Insets(10)); + root.setPadding(new Insets(Theme.S3)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); Scene scene = new Scene(root, screenSize().getWidth() * 0.78, screenSize().getHeight() * 0.78); scene.getAccelerators().put( @@ -178,33 +192,30 @@ class DiffWindow { private VBox buildInputSide(String title, boolean isLeft) { Label titleLabel = new Label(title); - titleLabel.setFont(titleFont()); - titleLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: #424242;"); - titleLabel.setPadding(new Insets(0, 0, 0, 4)); + titleLabel.setFont(Theme.bold((int) titleFont().getSize())); + titleLabel.setTextFill(Theme.TEXT_PRIMARY); + titleLabel.setPadding(new Insets(0, 0, 0, Theme.S1)); - Button pasteBtn = tinyButton("粘贴"); - Button clearBtn = tinyButton("清空"); - HBox btnGroup = new HBox(4, pasteBtn, clearBtn); + Button pasteBtn = StyledButton.small("粘贴", Theme.base(bodyFontSize() - 1)); + Button clearBtn = StyledButton.small("清空", Theme.base(bodyFontSize() - 1)); + HBox btnGroup = new HBox(Theme.S1, 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)); + header.setPadding(new Insets(0, 0, Theme.S1, 0)); TextArea area = new TextArea(); area.setFont(monoFont()); - area.setStyle(BORDER_STYLE + "-fx-background-insets: 0; -fx-background-radius: 3;"); + area.setStyle(Theme.STYLE_TEXT_AREA); area.setWrapText(false); pasteBtn.setOnAction(e -> pasteFromClipboard(area)); - clearBtn.setOnAction(e -> { - area.clear(); - refreshLineCount(); - }); + clearBtn.setOnAction(e -> { area.clear(); refreshLineCount(); }); area.textProperty().addListener((obs, old, val) -> refreshLineCount()); - VBox panel = new VBox(3, header, area); + VBox panel = new VBox(Theme.S1, header, area); VBox.setVgrow(area, Priority.ALWAYS); if (isLeft) leftArea = area; else rightArea = area; @@ -213,41 +224,39 @@ class DiffWindow { private VBox buildResultSection() { Label titleLabel = new Label("对比结果"); - titleLabel.setFont(titleFont()); - titleLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: #424242;"); - titleLabel.setPadding(new Insets(0, 0, 0, 4)); + titleLabel.setFont(Theme.bold((int) titleFont().getSize())); + titleLabel.setTextFill(Theme.TEXT_PRIMARY); + titleLabel.setPadding(new Insets(0, 0, 0, Theme.S1)); - Button prevBtn = tinyButton("▲上一处差异"); - Button nextBtn = tinyButton("▼下一处差异"); - Button copyBtn = tinyButton("复制结果"); - Button saveBtn = tinyButton("保存报告"); + Button prevBtn = StyledButton.small("▲ 上一处", Theme.base(bodyFontSize() - 1)); + Button nextBtn = StyledButton.small("▼ 下一处", Theme.base(bodyFontSize() - 1)); + Button copyBtn = StyledButton.small("复制结果", Theme.base(bodyFontSize() - 1)); + Button saveBtn = StyledButton.small("保存报告", Theme.base(bodyFontSize() - 1)); Label diffCounter = new Label(""); - diffCounter.setFont(Font.font("System", bodyFontSize() - 1)); - diffCounter.setTextFill(Color.rgb(117, 117, 117)); + diffCounter.setFont(Theme.base(bodyFontSize() - 1)); + diffCounter.setTextFill(Theme.TEXT_SECONDARY); 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); + HBox headerRight = new HBox(Theme.S1, prevBtn, nextBtn, diffCounter, 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)); + header.setPadding(new Insets(0, 0, Theme.S1, 0)); webView = new WebView(); webEngine = webView.getEngine(); - webView.setStyle(BORDER_STYLE + "-fx-background-insets: 0; -fx-background-radius: 3;"); - - // 保存计数器引用供 doCompare 更新 - // 用 userData 挂载,避免新增字段 + webView.setStyle("-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";" + + "-fx-background-insets: 0; -fx-background-radius: " + Theme.RADIUS_SM + ";"); webView.setUserData(diffCounter); - VBox panel = new VBox(3, header, webView); + VBox panel = new VBox(Theme.S1, header, webView); VBox.setVgrow(webView, Priority.ALWAYS); return panel; } @@ -854,39 +863,10 @@ class DiffWindow { 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)); + statusLabel.setTextFill(isError ? Theme.DANGER : Theme.TEXT_SECONDARY); } } - // ==================== 按钮样式 ==================== - - private Button accentButton(String text) { - Button btn = new Button(text); - btn.setFont(Font.font("System", btnFontSize())); - 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", btnFontSize())); - 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", btnFontSize() - 1)); - btn.setStyle("-fx-background-color: #f5f5f5; -fx-text-fill: #616161;" - + "-fx-padding: 4 12 4 12;" - + "-fx-background-radius: 3; -fx-cursor: hand;"); - return btn; - } - private static Rectangle2D screenSize() { return Screen.getPrimary().getVisualBounds(); } diff --git a/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java b/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java index a900f56..2b204e6 100644 --- a/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java +++ b/src/main/java/com/example/workutils/biz/git/GitSyncPlugin.java @@ -3,7 +3,10 @@ package com.example.workutils.biz.git; import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.plugin.Plugin; import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; import com.example.workutils.system.support.FileOperationHelper; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -46,6 +49,7 @@ public class GitSyncPlugin implements Plugin, InitializingBean { .version("1.0.0") .description("切换分支 + 拉取最新代码,损坏确认后重拉") .order(30) + .icon("🔄") .build(); private static final String[] MENU_OPTIONS = { @@ -148,6 +152,42 @@ public class GitSyncPlugin implements Plugin, InitializingBean { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private GitSyncWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new GitSyncWindow(GitSyncPlugin.this, fileOperationHelper, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new GitSyncWindow(GitSyncPlugin.this, fileOperationHelper, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "git-sync-close").start(); + } + }; + } + @Override public String checkConfig() { Path ppf = properties.getProjectPathsFilePath(); @@ -736,7 +776,193 @@ public class GitSyncPlugin implements Plugin, InitializingBean { return System.getProperty("os.name").toLowerCase().contains("win"); } - // ==================== 内部类 ==================== + // ==================== 供可视化窗口调用的公开 API ==================== + + /** + * 同步回调接口 —— 用于 UI 窗口接收实时进度 + */ + public interface SyncCallback { + void onLog(String message); + void onProgress(int current, int total, String repoName, String status, boolean error); + void onSyncDone(SyncSummary summary); + void onDamagedRepos(List repos); + } + + /** 同步结果汇总 */ + public static class SyncSummary { + public int total; + public int success; + public int damaged; + public int failed; + public long elapsedSeconds; + } + + /** + * 以回调方式同步所有项目(供可视窗口调用) + */ + public void syncWithCallback(String branch, SyncCallback cb) { + List projects; + try { + projects = fileOperationHelper.getProjectPathsWithStatus(); + } catch (IOException e) { + log.error("读取项目路径配置文件失败", e); + cb.onLog("[ERROR] 读取项目路径配置文件失败: " + e.getMessage()); + return; + } + if (projects.isEmpty()) { + cb.onLog("[WARN] 未配置任何项目路径"); + return; + } + + Map baseUrlMap = new LinkedHashMap<>(); + for (FileOperationHelper.ProjectPathInfo proj : projects) { + if (proj.getRemoteBaseUrl() != null && !proj.getRemoteBaseUrl().isEmpty()) { + String url = proj.getRemoteBaseUrl(); + if (!url.endsWith("/")) url = url + "/"; + baseUrlMap.put(normalizePath(proj.getPath()), url); + } + } + + int total = projects.size(); + Map resultMap = new LinkedHashMap<>(); + AtomicInteger completed = new AtomicInteger(0); + CountDownLatch latch = new CountDownLatch(total); + ExecutorService pool = Executors.newFixedThreadPool(PARALLEL); + long startTime = System.currentTimeMillis(); + + cb.onLog("目标分支: " + branch); + cb.onLog("项目总数: " + total); + cb.onLog("并行线程: " + PARALLEL); + cb.onLog("开始同步...\n"); + + for (int i = 0; i < total; i++) { + final FileOperationHelper.ProjectPathInfo proj = projects.get(i); + final String repoPath = proj.getPath(); + final String repoName = extractName(repoPath); + final String targetBranch = branch; + final int taskNum = i + 1; + + pool.submit(() -> { + cb.onLog("[" + taskNum + "/" + total + "] 处理中 " + repoName + " ..."); + try { + SyncResult result = syncOneRepo(repoPath, targetBranch, baseUrlMap); + resultMap.put(repoPath, result); + int done = completed.incrementAndGet(); + String tag; + switch (result) { + case OK: tag = "成功"; break; + case DAMAGED: tag = "损坏"; break; + default: tag = "失败"; break; + } + cb.onProgress(done, total, repoName, tag, result != SyncResult.OK); + } catch (Exception e) { + SyncResult r = SyncResult.FAILED; + r.error = e.getMessage(); + resultMap.put(repoPath, r); + int done = completed.incrementAndGet(); + cb.onProgress(done, total, repoName, "异常", true); + } finally { + latch.countDown(); + } + }); + } + + pool.shutdown(); + try { + boolean allDone = latch.await(SYNC_TOTAL_TIMEOUT_MINUTES, TimeUnit.MINUTES); + if (!allDone) { + pool.shutdownNow(); + cb.onLog("\n[ERROR] 同步超时(超过 " + SYNC_TOTAL_TIMEOUT_MINUTES + " 分钟),强制终止"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + pool.shutdownNow(); + cb.onLog("[ERROR] 并行处理被中断"); + return; + } + + int success = 0, failed = 0; + List damagedRepos = new ArrayList<>(); + for (FileOperationHelper.ProjectPathInfo proj : projects) { + SyncResult result = resultMap.get(proj.getPath()); + if (result == null) { failed++; continue; } + switch (result) { + case OK: success++; break; + case DAMAGED: + damagedRepos.add(new DamagedRepo(proj.getPath(), extractName(proj.getPath()), + branch, result.error != null ? result.error : "未知错误", result.remoteUrl)); + break; + case FAILED: failed++; break; + } + } + + long elapsed = (System.currentTimeMillis() - startTime) / 1000; + SyncSummary summary = new SyncSummary(); + summary.total = projects.size(); + summary.success = success; + summary.damaged = damagedRepos.size(); + summary.failed = failed; + summary.elapsedSeconds = elapsed; + + this.lastDamagedRepos = damagedRepos; + this.lastBranch = branch; + saveDamagedRepos(); + + cb.onSyncDone(summary); + if (!damagedRepos.isEmpty()) { + cb.onDamagedRepos(damagedRepos); + } + } + + /** + * 获取损坏仓库的 clone URL(供窗口使用) + */ + public String getCloneUrlFor(DamagedRepo dr) { + Map baseUrlMap = buildBaseUrlMap(); + return resolveCloneUrl(dr, baseUrlMap); + } + + /** + * 执行单个仓库重拉(供窗口调用) + */ + public boolean recloneOne(DamagedRepo dr, String cloneUrl) { + return doReclone(dr, cloneUrl); + } + + /** + * 清除损坏仓库缓存 + */ + public void clearDamagedCache() { + this.lastDamagedRepos = null; + this.lastBranch = null; + try { + Path file = properties.getDamagedReposFilePath(); + Files.deleteIfExists(file); + } catch (IOException ignored) {} + } + + /** + * 获取上次缓存的损坏仓库列表 + */ + public List getLastDamagedRepos() { + return lastDamagedRepos; + } + + /** + * 获取上次使用的分支 + */ + public String getLastBranch() { + return lastBranch; + } + + /** + * 获取项目路径信息 + */ + public List getProjectPaths() throws IOException { + return fileOperationHelper.getProjectPathsWithStatus(); + } + + // ==================== 内部类(公开,供窗口使用) ==================== private enum SyncResult { OK, DAMAGED, FAILED; @@ -745,13 +971,13 @@ public class GitSyncPlugin implements Plugin, InitializingBean { String remoteUrl; } - /** 损坏仓库信息,供阶段二确认用 */ - private static class DamagedRepo { - final String path; - final String name; - final String branch; - final String error; - final String remoteUrl; + /** 损坏仓库信息 */ + public static class DamagedRepo { + public final String path; + public final String name; + public final String branch; + public final String error; + public final String remoteUrl; DamagedRepo(String path, String name, String branch, String error, String remoteUrl) { this.path = path; @@ -761,7 +987,7 @@ public class GitSyncPlugin implements Plugin, InitializingBean { this.remoteUrl = remoteUrl; } - boolean hasRemoteUrl() { + public boolean hasRemoteUrl() { return remoteUrl != null && !remoteUrl.isEmpty(); } } diff --git a/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java b/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java new file mode 100644 index 0000000..b97bb1f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/git/GitSyncWindow.java @@ -0,0 +1,512 @@ +package com.example.workutils.biz.git; + +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.layout.*; +import javafx.scene.Parent; +import javafx.stage.Stage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Git 仓库同步可视化窗口 + *

+ * 三个 Tab:项目列表、同步执行、损坏仓库处理 + */ +public class GitSyncWindow { + + private final Stage stage; + private final CountDownLatch closeLatch; + private final GitSyncPlugin plugin; + private final WorkUtilsProperties properties; + private final FileOperationHelper fileOperationHelper; + + // Tab 1: 项目列表 + private TableView projectTable; + private final ObservableList projectRows = FXCollections.observableArrayList(); + + // Tab 2: 同步执行 + private TextField branchField; + private Button syncBtn; + private Button stopBtn; + private ProgressBar progressBar; + private Label progressLabel; + private TextArea logArea; + private final AtomicBoolean stopRequested = new AtomicBoolean(false); + + // Tab 3: 损坏仓库处理 + private TableView damagedTable; + private final ObservableList damagedRows = FXCollections.observableArrayList(); + private Tab tabDamaged; + + // ---- 字体快捷 ---- + + public GitSyncWindow(GitSyncPlugin plugin, FileOperationHelper fileOperationHelper, + WorkUtilsProperties properties, CountDownLatch closeLatch) { + this.plugin = plugin; + this.fileOperationHelper = fileOperationHelper; + this.properties = properties; + this.closeLatch = closeLatch; + this.stage = new Stage(); + buildUI(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { return stage; } + + // ==================== UI 构建 ==================== + + private void buildUI() { + stage.setTitle("Git 仓库同步"); + stage.setWidth(900); + stage.setHeight(650); + stage.setMinWidth(750); + stage.setMinHeight(500); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + TabPane tabPane = new TabPane(); + tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabPane.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + tabPane.getTabs().addAll( + buildProjectListTab(), + buildSyncTab(), + buildDamagedTab() + ); + + VBox root = new VBox(tabPane); + VBox.setVgrow(tabPane, Priority.ALWAYS); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + Scene scene = new Scene(root); + stage.setScene(scene); + + refreshProjectList(); + } + + // ==================== Tab 1: 项目列表 ==================== + + private Tab buildProjectListTab() { + Tab tab = new Tab(" 项目列表 "); + tab.setClosable(false); + + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4)); + + Label title = new Label("所有配置的项目路径"); + title.setFont(Theme.heading(16)); + title.setTextFill(Theme.TEXT_PRIMARY); + + HBox toolbar = new HBox(Theme.S2); + toolbar.setAlignment(Pos.CENTER_LEFT); + Button refreshBtn = StyledButton.secondary("刷新列表", Theme.base(13)); + refreshBtn.setOnAction(e -> refreshProjectList()); + Label countLabel = new Label(); + countLabel.setFont(Theme.base(13)); + countLabel.setTextFill(Theme.TEXT_SECONDARY); + countLabel.textProperty().bind( + javafx.beans.binding.Bindings.size(projectRows).asString("共 %d 个项目")); + + toolbar.getChildren().addAll(refreshBtn, countLabel); + + projectTable = new TableView<>(); + projectTable.setItems(projectRows); + projectTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + projectTable.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + + TableColumn colName = new TableColumn<>("项目名"); + colName.setCellValueFactory(new PropertyValueFactory<>("name")); + + TableColumn colPath = new TableColumn<>("路径"); + colPath.setCellValueFactory(new PropertyValueFactory<>("path")); + + TableColumn colStatus = new TableColumn<>("Git 状态"); + colStatus.setCellValueFactory(new PropertyValueFactory<>("gitStatus")); + + TableColumn colRemote = new TableColumn<>("远程URL"); + colRemote.setCellValueFactory(new PropertyValueFactory<>("remoteUrl")); + + projectTable.getColumns().addAll(colName, colPath, colStatus, colRemote); + + // 列宽百分比自适应:项目名18% 路径38% Git状态12% 远程URL32% + colName.prefWidthProperty().bind(projectTable.widthProperty().subtract(18).multiply(0.18)); + colPath.prefWidthProperty().bind(projectTable.widthProperty().subtract(18).multiply(0.38)); + colStatus.prefWidthProperty().bind(projectTable.widthProperty().subtract(18).multiply(0.12)); + colRemote.prefWidthProperty().bind(projectTable.widthProperty().subtract(18).multiply(0.32)); + + VBox.setVgrow(projectTable, Priority.ALWAYS); + box.getChildren().addAll(title, toolbar, projectTable); + tab.setContent(box); + return tab; + } + + private void refreshProjectList() { + projectRows.clear(); + try { + List projects = fileOperationHelper.getProjectPathsWithStatus(); + for (FileOperationHelper.ProjectPathInfo p : projects) { + String name = fileOperationHelper.extractProjectName(p.getPath()); + String gitStatus = checkGitStatus(p.getPath()); + String remote = p.getRemoteBaseUrl(); + projectRows.add(new ProjectRow(name, p.getPath(), gitStatus, remote)); + } + } catch (IOException e) { + projectRows.add(new ProjectRow("(错误)", e.getMessage(), "", "")); + } + } + + private String checkGitStatus(String path) { + java.io.File gitDir = new java.io.File(path, ".git"); + if (!new java.io.File(path).exists()) return "目录不存在"; + if (!gitDir.exists()) return "非Git仓库"; + return "正常"; + } + + // ==================== Tab 2: 同步执行 ==================== + + private Tab buildSyncTab() { + Tab tab = new Tab(" 同步执行 "); + tab.setClosable(false); + + VBox box = new VBox(Theme.S3); + box.setPadding(new Insets(Theme.S4)); + + // 分支输入 + HBox branchRow = new HBox(Theme.S2); + branchRow.setAlignment(Pos.CENTER_LEFT); + Label branchLabel = new Label("目标分支:"); + branchLabel.setFont(Theme.base(14)); + branchLabel.setTextFill(Theme.TEXT_PRIMARY); + branchField = new TextField("master"); + branchField.setFont(Theme.base(14)); + branchField.setStyle(Theme.STYLE_TEXT_FIELD); + branchField.setPrefWidth(200); + + syncBtn = StyledButton.primary("开始同步", Theme.bold(14)); + syncBtn.setOnAction(e -> startSync()); + + stopBtn = StyledButton.danger("终止", Theme.base(14)); + stopBtn.setDisable(true); + stopBtn.setOnAction(e -> stopRequested.set(true)); + + branchRow.getChildren().addAll(branchLabel, branchField, syncBtn, stopBtn); + + // 进度条 + HBox progressRow = new HBox(Theme.S2); + progressRow.setAlignment(Pos.CENTER_LEFT); + progressBar = new ProgressBar(0); + progressBar.setPrefWidth(400); + progressBar.setPrefHeight(24); + progressLabel = new Label("就绪"); + progressLabel.setFont(Theme.base(13)); + progressLabel.setTextFill(Theme.TEXT_SECONDARY); + progressRow.getChildren().addAll(progressBar, progressLabel); + + // 日志区域 + Label logTitle = new Label("执行日志"); + logTitle.setFont(Theme.heading(14)); + logTitle.setTextFill(Theme.TEXT_PRIMARY); + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setFont(Theme.mono(13)); + logArea.setStyle(Theme.STYLE_LOG_AREA); + VBox.setVgrow(logArea, Priority.ALWAYS); + + box.getChildren().addAll(branchRow, progressRow, logTitle, logArea); + tab.setContent(box); + return tab; + } + + private void startSync() { + String input = branchField.getText().trim(); + final String branch = input.isEmpty() ? "master" : input; + + syncBtn.setDisable(true); + stopBtn.setDisable(false); + stopRequested.set(false); + logArea.clear(); + progressBar.setProgress(0); + progressLabel.setText("正在同步..."); + + new Thread(() -> { + plugin.syncWithCallback(branch, new GitSyncPlugin.SyncCallback() { + @Override + public void onLog(String message) { + Platform.runLater(() -> { + logArea.appendText(message + "\n"); + }); + } + + @Override + public void onProgress(int current, int total, String repoName, String status, boolean error) { + Platform.runLater(() -> { + progressBar.setProgress((double) current / total); + progressLabel.setText(String.format("%d/%d %s [%s]", current, total, repoName, status)); + if (error) { + logArea.appendText("[" + current + "/" + total + "] " + repoName + " >> " + status + "\n"); + } + }); + } + + @Override + public void onSyncDone(GitSyncPlugin.SyncSummary summary) { + Platform.runLater(() -> { + syncBtn.setDisable(false); + stopBtn.setDisable(true); + progressBar.setProgress(1); + progressLabel.setText("完成"); + logArea.appendText("\n========== 同步完成 ==========\n"); + logArea.appendText(String.format("总计: %d | 成功: %d | 损坏: %d | 失败: %d | 耗时: %ds\n", + summary.total, summary.success, summary.damaged, summary.failed, summary.elapsedSeconds)); + if (summary.damaged == 0) { + logArea.appendText("全部同步成功!\n"); + } else { + logArea.appendText("请切换到「损坏仓库处理」Tab 查看详情\n"); + } + }); + } + + @Override + public void onDamagedRepos(List repos) { + Platform.runLater(() -> { + refreshDamagedTable(repos); + }); + } + }); + }, "GitSync-Worker").start(); + } + + // ==================== Tab 3: 损坏仓库处理 ==================== + + private Tab buildDamagedTab() { + tabDamaged = new Tab(" 损坏仓库处理 "); + tabDamaged.setClosable(false); + + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4)); + + Label title = new Label("以下仓库无法正常同步,需要重新拉取"); + title.setFont(Theme.bold(14)); + title.setTextFill(Theme.DANGER); + + // 表格 + damagedTable = new TableView<>(); + damagedTable.setItems(damagedRows); + damagedTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + damagedTable.setPrefHeight(350); + damagedTable.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + + TableColumn colName2 = new TableColumn<>("项目名"); + colName2.setCellValueFactory(new PropertyValueFactory<>("name")); + + TableColumn colPath2 = new TableColumn<>("路径"); + colPath2.setCellValueFactory(new PropertyValueFactory<>("path")); + + TableColumn colError = new TableColumn<>("错误原因"); + colError.setCellValueFactory(new PropertyValueFactory<>("error")); + + TableColumn colRemote2 = new TableColumn<>("远程URL"); + colRemote2.setCellValueFactory(new PropertyValueFactory<>("cloneUrl")); + + TableColumn colAction = new TableColumn<>("操作"); + colAction.setCellValueFactory(new PropertyValueFactory<>("actionBtn")); + colAction.setCellFactory(col -> new TableCell() { + private final Button btn = new Button("重新拉取"); + { + btn.setFont(Theme.base(12)); + btn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.WARNING) + ";" + + "-fx-text-fill: white; -fx-cursor: hand;" + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); + } + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + } else { + DamagedRow row = getTableView().getItems().get(getIndex()); + btn.setOnAction(e -> recloneSingle(row)); + setGraphic(btn); + } + } + }); + + damagedTable.getColumns().addAll(colName2, colPath2, colError, colRemote2, colAction); + + // 列宽百分比自适应:项目名14% 路径26% 错误22% 远程28% 操作10% + colName2.prefWidthProperty().bind(damagedTable.widthProperty().subtract(18).multiply(0.14)); + colPath2.prefWidthProperty().bind(damagedTable.widthProperty().subtract(18).multiply(0.26)); + colError.prefWidthProperty().bind(damagedTable.widthProperty().subtract(18).multiply(0.22)); + colRemote2.prefWidthProperty().bind(damagedTable.widthProperty().subtract(18).multiply(0.28)); + colAction.prefWidthProperty().bind(damagedTable.widthProperty().subtract(18).multiply(0.10)); + + // 操作按钮 + HBox actions = new HBox(Theme.S2); + actions.setAlignment(Pos.CENTER_LEFT); + + Button recloneAllBtn = StyledButton.danger("全部重新拉取", Theme.bold(14)); + recloneAllBtn.setOnAction(e -> recloneAll()); + + Button clearBtn = StyledButton.secondary("清除记录", Theme.base(13)); + clearBtn.setOnAction(e -> { + plugin.clearDamagedCache(); + damagedRows.clear(); + logArea.appendText("[INFO] 损坏仓库记录已清除\n"); + }); + + Button loadCacheBtn = StyledButton.secondary("加载上次记录", Theme.base(13)); + loadCacheBtn.setOnAction(e -> { + List cached = plugin.getLastDamagedRepos(); + if (cached != null && !cached.isEmpty()) { + refreshDamagedTable(cached); + } else { + logArea.appendText("[INFO] 暂无损坏仓库记录\n"); + } + }); + + actions.getChildren().addAll(recloneAllBtn, clearBtn, loadCacheBtn); + + VBox.setVgrow(damagedTable, Priority.ALWAYS); + box.getChildren().addAll(title, damagedTable, actions); + tabDamaged.setContent(box); + return tabDamaged; + } + + private void refreshDamagedTable(List repos) { + damagedRows.clear(); + for (GitSyncPlugin.DamagedRepo dr : repos) { + String cloneUrl = plugin.getCloneUrlFor(dr); + damagedRows.add(new DamagedRow(dr.name, dr.path, dr.error, cloneUrl, dr)); + } + } + + private void recloneSingle(DamagedRow row) { + String cloneUrl = row.getCloneUrl(); + if (cloneUrl == null || cloneUrl.isEmpty()) { + Platform.runLater(() -> { + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("无法重新拉取"); + alert.setHeaderText(row.getName()); + alert.setContentText("无法获取远程仓库 URL,请检查项目路径配置。"); + alert.showAndWait(); + }); + return; + } + + logArea.appendText("\n重拉 " + row.getName() + " ...\n"); + new Thread(() -> { + boolean ok = plugin.recloneOne(row.getDamagedRepo(), cloneUrl); + Platform.runLater(() -> { + if (ok) { + logArea.appendText(" " + row.getName() + " clone 成功\n"); + damagedRows.remove(row); + } else { + logArea.appendText(" " + row.getName() + " clone 失败\n"); + } + }); + }, "Reclone-" + row.getName()).start(); + } + + private void recloneAll() { + if (damagedRows.isEmpty()) return; + + Alert confirm = new Alert(Alert.AlertType.CONFIRMATION); + confirm.setTitle("确认"); + confirm.setHeaderText(String.format("全部重新拉取 %d 个仓库?", damagedRows.size())); + confirm.setContentText("将删除本地目录并重新 clone,不可撤销。"); + confirm.showAndWait().ifPresent(r -> { + if (r == ButtonType.OK) { + List snapshot = new ArrayList<>(damagedRows); + damagedRows.clear(); + logArea.appendText("\n========== 开始批量重拉 ==========\n"); + new Thread(() -> { + int ok = 0, fail = 0; + for (DamagedRow row : snapshot) { + String cloneUrl = row.getCloneUrl(); + if (cloneUrl == null || cloneUrl.isEmpty()) { + fail++; + continue; + } + Platform.runLater(() -> + logArea.appendText("重拉 " + row.getName() + " ...\n")); + if (plugin.recloneOne(row.getDamagedRepo(), cloneUrl)) { + ok++; + } else { + fail++; + } + } + int finalOk = ok, finalFail = fail; + Platform.runLater(() -> + logArea.appendText(String.format("批量重拉完成: 成功 %d, 失败 %d\n", finalOk, finalFail))); + }, "Reclone-All").start(); + } + }); + } + + // ==================== 数据行类 ==================== + + public static class ProjectRow { + private final String name; + private final String path; + private final String gitStatus; + private final String remoteUrl; + + ProjectRow(String name, String path, String gitStatus, String remoteUrl) { + this.name = name; + this.path = path; + this.gitStatus = gitStatus; + this.remoteUrl = remoteUrl; + } + public String getName() { return name; } + public String getPath() { return path; } + public String getGitStatus() { return gitStatus; } + public String getRemoteUrl() { return remoteUrl; } + } + + public static class DamagedRow { + private final String name; + private final String path; + private final String error; + private final String cloneUrl; + private final GitSyncPlugin.DamagedRepo damagedRepo; + + DamagedRow(String name, String path, String error, String cloneUrl, GitSyncPlugin.DamagedRepo damagedRepo) { + this.name = name; + this.path = path; + this.error = error; + this.cloneUrl = cloneUrl; + this.damagedRepo = damagedRepo; + } + public String getName() { return name; } + public String getPath() { return path; } + public String getError() { return error; } + public String getCloneUrl() { return cloneUrl; } + public String getActionBtn() { return "重新拉取"; } + public GitSyncPlugin.DamagedRepo getDamagedRepo() { return damagedRepo; } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportPlugin.java b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportPlugin.java index 6ea9b0f..4a1c044 100644 --- a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportPlugin.java +++ b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportPlugin.java @@ -1,14 +1,19 @@ package com.example.workutils.biz.mybatisexport; import com.example.workutils.biz.mybatisexport.service.MyBatisExportService; +import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.plugin.PluginDescriptor; import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.VisualPluginFactory; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.Arrays; import java.util.Scanner; +import java.util.concurrent.CountDownLatch; /** * MyBatis SQL导出插件(实现 Tool 接口) @@ -24,6 +29,7 @@ public class MyBatisExportPlugin implements Plugin { .version("1.0.0") .description("从MyBatis XML中提取SQL并导出为CSV") .order(60) + .icon("🗃️") .build(); private static final String[] MENU_OPTIONS = { @@ -36,6 +42,7 @@ public class MyBatisExportPlugin implements Plugin { }; private final MyBatisExportService myBatisExportService; + private final WorkUtilsProperties properties; @Override public PluginDescriptor getDescriptor() { @@ -52,6 +59,42 @@ public class MyBatisExportPlugin implements Plugin { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private MyBatisExportWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new MyBatisExportWindow(myBatisExportService, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new MyBatisExportWindow(myBatisExportService, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "mybatisexport-close").start(); + } + }; + } + @Override public void executeCommand(String... args) throws Exception { if (args.length == 0) { diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java new file mode 100644 index 0000000..fce2b42 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mybatisexport/MyBatisExportWindow.java @@ -0,0 +1,433 @@ +package com.example.workutils.biz.mybatisexport; + +import com.example.workutils.biz.mybatisexport.service.MyBatisExportService; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.HistoryPersistence; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.layout.*; +import javafx.scene.Parent; +import javafx.stage.Stage; + +import java.awt.Desktop; +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * MyBatis SQL 导出可视化窗口 + *

+ * 支持按全部/表名/关键字/标签ID过滤,结果自动导出CSV,历史记录可追溯打开 + */ +public class MyBatisExportWindow { + + private final Stage stage; + private final CountDownLatch closeLatch; + private final MyBatisExportService exportService; + private final WorkUtilsProperties properties; + + // 过滤 + private ComboBox filterModeCmb; + private TextField filterValueField; + private Label filterHint; + + // 操作 + private Button exportBtn; + private ProgressBar progressBar; + private Label progressLabel; + + // 历史记录 + private TableView historyTable; + private final ObservableList historyRows = FXCollections.observableArrayList(); + + private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private int historyCounter = 0; + private final HistoryPersistence historyPersistence; + private static final String DELIMITER = "|"; + + public MyBatisExportWindow(MyBatisExportService exportService, WorkUtilsProperties properties, + CountDownLatch closeLatch) { + this.exportService = exportService; + this.properties = properties; + this.closeLatch = closeLatch; + this.historyPersistence = new HistoryPersistence("mybatis_export_history.txt"); + this.stage = new Stage(); + buildUI(); + loadHistory(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { return stage; } + + private int fsSection() { return properties.getFontSectionTitle(); } + private int fsBtn() { return properties.getFontButton(); } + private int fsBody() { return properties.getFontBody(); } + + private void buildUI() { + stage.setTitle("MyBatis SQL 导出"); + stage.setWidth(960); + stage.setHeight(680); + stage.setMinWidth(750); + stage.setMinHeight(500); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + VBox root = new VBox(Theme.S3); + root.setPadding(new Insets(Theme.S4)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + // === 过滤条件 === + HBox filterRow = new HBox(Theme.S3); + filterRow.setAlignment(Pos.CENTER_LEFT); + filterRow.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); + filterRow.setStyle(Theme.STYLE_CARD); + + Label modeLabel = new Label("过滤方式:"); + modeLabel.setFont(Theme.base(fsBody())); + modeLabel.setTextFill(Theme.TEXT_PRIMARY); + filterModeCmb = new ComboBox<>(FXCollections.observableArrayList( + "全部SQL", "按表名", "按关键字", "按标签ID")); + filterModeCmb.setValue("全部SQL"); + filterModeCmb.setStyle(Theme.STYLE_COMBO + "-fx-font-family: 'Microsoft YaHei'; -fx-font-size: " + (fsBody() - 1) + "px;"); + filterModeCmb.setPrefWidth(Math.max(150, fsBody() * 11)); + + filterValueField = new TextField(); + filterValueField.setFont(Theme.base(fsBody())); + filterValueField.setStyle(Theme.STYLE_TEXT_FIELD); + filterValueField.setPrefWidth(250); + filterValueField.setPromptText("输入过滤值"); + filterValueField.setDisable(true); + + filterHint = new Label(""); + filterHint.setFont(Theme.caption(fsBody() - 2)); + filterHint.setTextFill(Theme.TEXT_MUTED); + + filterModeCmb.setOnAction(e -> { + String mode = filterModeCmb.getValue(); + filterValueField.setDisable("全部SQL".equals(mode)); + if ("按表名".equals(mode)) filterHint.setText("多个表名用逗号分隔"); + else if ("按关键字".equals(mode)) filterHint.setText("支持正则表达式"); + else filterHint.setText(""); + }); + + exportBtn = StyledButton.primary("导出", Theme.bold(fsBtn())); + exportBtn.setOnAction(e -> startExport()); + + filterRow.getChildren().addAll(modeLabel, filterModeCmb, filterValueField, filterHint, exportBtn); + + // === 进度 === + HBox progressRow = new HBox(Theme.S2); + progressRow.setAlignment(Pos.CENTER_LEFT); + progressBar = new ProgressBar(0); + progressBar.setPrefWidth(300); + progressBar.setPrefHeight(16); + progressBar.setVisible(false); + progressLabel = new Label(""); + progressLabel.setFont(Theme.base(fsBody() - 2)); + progressLabel.setTextFill(Theme.TEXT_SECONDARY); + progressRow.getChildren().addAll(progressBar, progressLabel); + + // === 标题 === + Label tableTitle = new Label("导出历史记录"); + tableTitle.setFont(Theme.heading(fsSection())); + tableTitle.setTextFill(Theme.TEXT_PRIMARY); + + // === 历史记录表格 === + historyTable = new TableView<>(); + historyTable.setItems(historyRows); + historyTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + historyTable.setPlaceholder(new Label("暂无导出记录")); + historyTable.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + + // 列定义 + TableColumn colIndex = new TableColumn<>("序号"); + colIndex.setCellValueFactory(new PropertyValueFactory<>("index")); + + TableColumn colQuery = new TableColumn<>("查询条件"); + colQuery.setCellValueFactory(new PropertyValueFactory<>("queryCondition")); + + TableColumn colFile = new TableColumn<>("生成文件"); + colFile.setCellValueFactory(new PropertyValueFactory<>("fileDisplay")); + + TableColumn colCount = new TableColumn<>("导出数"); + colCount.setCellValueFactory(new PropertyValueFactory<>("recordCount")); + + TableColumn colTime = new TableColumn<>("时间"); + colTime.setCellValueFactory(new PropertyValueFactory<>("timestamp")); + + TableColumn colAction = new TableColumn<>("操作"); + colAction.setCellValueFactory(new PropertyValueFactory<>("actionLabel")); + colAction.setCellFactory(col -> buildActionCell()); + + historyTable.getColumns().addAll(colIndex, colQuery, colFile, colCount, colTime, colAction); + + // 列宽百分比自适应:序号5% 查询22% 文件30% 导出8% 时间17% 操作18% + colIndex.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.05)); + colQuery.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.22)); + colFile.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.30)); + colCount.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.08)); + colTime.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.17)); + colAction.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.18)); + + VBox.setVgrow(historyTable, Priority.ALWAYS); + root.getChildren().addAll(filterRow, progressRow, tableTitle, historyTable); + stage.setScene(new Scene(root)); + } + + /** + * 构建操作列单元格,包含「打开」按钮和右键菜单 + */ + private TableCell buildActionCell() { + TableCell cell = new TableCell() { + private final Button openBtn = new Button("打开文件"); + private final Button dirBtn = new Button("打开目录"); + private final HBox btnBox = new HBox(4, openBtn, dirBtn); + private final ContextMenu menu = new ContextMenu(); + private String currentFileName; + + { + openBtn.setFont(Theme.base(fsBody() - 2)); + openBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";" + + "-fx-text-fill: white; -fx-cursor: hand;" + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); + + dirBtn.setFont(Theme.base(fsBody() - 2)); + dirBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + "; -fx-cursor: hand;" + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 6;"); + dirBtn.setTooltip(new Tooltip("打开文件所在目录")); + + // 右键菜单选项 + MenuItem defaultOpen = new MenuItem("系统默认程序打开"); + defaultOpen.setOnAction(e -> openFile(currentFileName)); + MenuItem openNotepad = new MenuItem("用记事本打开"); + openNotepad.setOnAction(e -> openWithNotepad(currentFileName)); + MenuItem openFolder = new MenuItem("打开所在文件夹"); + openFolder.setOnAction(e -> openFolder(currentFileName)); + MenuItem copyPath = new MenuItem("复制文件路径"); + copyPath.setOnAction(e -> copyFilePath(currentFileName)); + menu.getItems().addAll(defaultOpen, openNotepad, openFolder, copyPath); + + // 左键「打开文件」→ 系统默认程序打开 + openBtn.setOnAction(e -> openFile(currentFileName)); + // 左键「打开目录」→ 打开所在目录 + dirBtn.setOnAction(e -> openFolder(currentFileName)); + // 右键点击 → 弹出菜单 + btnBox.setOnContextMenuRequested(e -> { + menu.show(btnBox, e.getScreenX(), e.getScreenY()); + }); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + currentFileName = null; + } else { + int idx = getIndex(); + if (idx >= 0 && getTableView() != null) { + HistoryRow row = getTableView().getItems().get(idx); + currentFileName = row != null ? row.getFileName() : null; + } + setGraphic(btnBox); + } + } + + private void copyFilePath(String path) { + if (path == null) return; + javafx.scene.input.Clipboard.getSystemClipboard() + .setContent(new javafx.scene.input.ClipboardContent() {{ + putString(path); + }}); + } + }; + return cell; + } + + // ==================== 历史持久化 ==================== + + /** + * 从 data/mybatis_export_history.txt 加载历史记录,新记录在前 + */ + private void loadHistory() { + List lines = historyPersistence.load(); + int hc = 0; + for (String line : lines) { + String[] parts = line.split("\\" + DELIMITER, 4); + if (parts.length == 4) { + hc++; + historyRows.add(new HistoryRow( + String.valueOf(hc), parts[0], parts[1], + Integer.parseInt(parts[2]), parts[3], "打开")); + } + } + historyCounter = hc; + } + + /** + * 将当前历史保存到 data/mybatis_export_history.txt,最新在前 + */ + private void saveHistory() { + List lines = new ArrayList<>(); + for (HistoryRow row : historyRows) { + lines.add(row.getQueryCondition() + DELIMITER + + row.getFileName() + DELIMITER + + row.getRecordCount() + DELIMITER + + row.getTimestamp()); + } + historyPersistence.save(lines); + } + + private void startExport() { + String mode = filterModeCmb.getValue(); + String val = filterValueField.getText().trim(); + + String filterMode; + String queryDisplay; + if ("全部SQL".equals(mode)) { + filterMode = "all"; + queryDisplay = "全部SQL"; + } else if ("按表名".equals(mode)) { + filterMode = "table"; + queryDisplay = "按表名: " + val; + } else if ("按关键字".equals(mode)) { + filterMode = "keyword"; + queryDisplay = "按关键字: " + val; + } else { + filterMode = "tagid"; + queryDisplay = "按标签ID: " + val; + } + + String prefix = "SQL " + (val.isEmpty() ? "全部" : val); + final String fQueryDisplay = queryDisplay; + + exportBtn.setDisable(true); + progressBar.setVisible(true); + progressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + progressLabel.setText("正在导出..."); + + new Thread(() -> { + try { + MyBatisExportService.ExportResult result = + exportService.exportWithResult(prefix, filterMode, val); + + int hc = ++historyCounter; + String timestamp = SDF.format(new Date()); + + Platform.runLater(() -> { + progressBar.setProgress(1); + progressBar.setVisible(false); + progressLabel.setText("完成"); + exportBtn.setDisable(false); + + // 添加历史记录行(最新在前) + historyRows.add(0, new HistoryRow( + String.valueOf(hc), fQueryDisplay, result.fileName, + result.rows.size(), timestamp, "打开")); + saveHistory(); + }); + } catch (Exception ex) { + Platform.runLater(() -> { + progressBar.setVisible(false); + progressLabel.setText("导出失败"); + exportBtn.setDisable(false); + }); + } + }, "SQLExport-Worker").start(); + } + + // ==================== 文件打开工具方法 ==================== + + private void openFile(String path) { + if (path == null) return; + try { + File f = new File(path); + if (!f.exists()) return; + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + new ProcessBuilder("cmd", "/c", "start", "", path).start(); + } else { + Desktop.getDesktop().open(f); + } + } catch (Exception ignored) { + } + } + + private void openWithNotepad(String path) { + if (path == null) return; + try { + new ProcessBuilder("notepad.exe", path).start(); + } catch (Exception e) { + openFile(path); + } + } + + private void openFolder(String path) { + if (path == null) return; + try { + File f = new File(path).getParentFile(); + if (f == null || !f.exists()) return; + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + new ProcessBuilder("explorer.exe", "/select,", path).start(); + } else { + Desktop.getDesktop().open(f); + } + } catch (Exception ignored) { + } + } + + // ==================== 数据行 ==================== + + public static class HistoryRow { + private final String index; + private final String queryCondition; + private final String fileName; + private final int recordCount; + private final String timestamp; + private final String actionLabel; + + HistoryRow(String index, String queryCondition, String fileName, + int recordCount, String timestamp, String actionLabel) { + this.index = index; + this.queryCondition = queryCondition; + this.fileName = fileName; + this.recordCount = recordCount; + this.timestamp = timestamp; + this.actionLabel = actionLabel; + } + + public String getIndex() { return index; } + public String getQueryCondition() { return queryCondition; } + public String getFileName() { return fileName; } + // 文件路径仅显示文件名 + public String getFileDisplay() { + return fileName.contains("\\") || fileName.contains("/") + ? fileName.substring(Math.max(fileName.lastIndexOf('\\'), fileName.lastIndexOf('/')) + 1) + : fileName; + } + public String getRecordCount() { return String.valueOf(recordCount); } + public String getTimestamp() { return timestamp; } + public String getActionLabel() { return actionLabel; } + } +} diff --git a/src/main/java/com/example/workutils/biz/mybatisexport/service/MyBatisExportService.java b/src/main/java/com/example/workutils/biz/mybatisexport/service/MyBatisExportService.java index 0665b8e..7bbf943 100644 --- a/src/main/java/com/example/workutils/biz/mybatisexport/service/MyBatisExportService.java +++ b/src/main/java/com/example/workutils/biz/mybatisexport/service/MyBatisExportService.java @@ -76,6 +76,80 @@ public class MyBatisExportService { new TagIdFilter(tagIdPattern), null); } + // ==================== 供可视化窗口调用的公开 API ==================== + + /** SQL 导出结果行 */ + public static class SqlExportRow { + public final String projectName; + public final String filePath; + public final String sqlId; + public final String sqlType; + public final String sqlText; + + SqlExportRow(String projectName, String filePath, String sqlId, String sqlType, String sqlText) { + this.projectName = projectName; + this.filePath = filePath; + this.sqlId = sqlId; + this.sqlType = sqlType; + this.sqlText = sqlText; + } + } + + /** 导出结果(含数据行与文件路径) */ + public static class ExportResult { + public final List rows; + public final String fileName; + + ExportResult(List rows, String fileName) { + this.rows = rows; + this.fileName = fileName; + } + } + + /** + * 按过滤条件导出并返回结果(含文件路径,供窗口展示 + 保存 CSV) + */ + public ExportResult exportWithResult(String fileNamePrefix, String filterMode, String filterValue) throws Exception { + SqlFilter filter = null; + if ("table".equals(filterMode) && filterValue != null && !filterValue.isEmpty()) { + filter = new TableFilter(filterValue); + } else if ("keyword".equals(filterMode) && filterValue != null && !filterValue.isEmpty()) { + filter = new KeywordFilter(filterValue); + } else if ("tagid".equals(filterMode) && filterValue != null && !filterValue.isEmpty()) { + filter = new TagIdFilter(filterValue); + } + + List projectPaths = fileOperationHelper.getProjectPaths(); + if (projectPaths.isEmpty()) { + log.warn("未找到项目路径"); + return new ExportResult(new ArrayList<>(), ""); + } + + List resultList = new ArrayList<>(); + + for (String projectPath : projectPaths) { + try { + List projectRecords = processProject(projectPath, filter, null); + for (String[] r : projectRecords) { + resultList.add(new SqlExportRow(r[0], r[1], r[2], r[3], r[4])); + } + } catch (Exception e) { + log.error("处理项目时出错: " + projectPath, e); + } + } + + // 同时保存 CSV + List csvRecords = new ArrayList<>(); + for (SqlExportRow row : resultList) { + csvRecords.add(new String[]{row.projectName, row.filePath, row.sqlId, row.sqlType, row.sqlText}); + } + String fileName = outputHelper.generateOutputFileName(fileNamePrefix, "csv"); + outputHelper.writeRecordsToCsv(csvRecords, fileName, Arrays.asList(EXPORT_HEADERS)); + + log.info("导出完成。{} 条记录,已保存到: {}", resultList.size(), fileName); + return new ExportResult(resultList, fileName); + } + private void exportWithFilter(String fileNamePrefix, SqlFilter filter, ProcessStrategy processStrategy) throws Exception { List projectPaths = fileOperationHelper.getProjectPaths(); diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorPlugin.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorPlugin.java index 414a85d..5aee249 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorPlugin.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorPlugin.java @@ -4,8 +4,11 @@ import com.example.workutils.biz.mybatisgenerator.service.generator.MyBatisGener import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.plugin.Plugin; import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; import com.example.workutils.system.support.FileOperationHelper; import com.example.workutils.system.support.JfxBootstrap; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -32,6 +35,7 @@ public class MyBatisGeneratorPlugin implements Plugin { .version("1.0.0") .description("根据数据库表结构生成PO/Mapper/XML") .order(20) + .icon("🗄️") .build(); private static final String[] MENU_OPTIONS = { @@ -48,6 +52,56 @@ public class MyBatisGeneratorPlugin implements Plugin { @Override public PluginDescriptor getDescriptor() { return DESCRIPTOR; } @Override public String[] getMenuOptions() { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private MyBatisGeneratorWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + private MyBatisGeneratorWindow.GeneratorCallback createCallback() { + return new MyBatisGeneratorWindow.GeneratorCallback() { + @Override public List queryTables(String pattern) throws Exception { + return generatorService.queryTablesByPattern(pattern); } + @Override public String getDbUrl() { return generatorService.getDatabaseUrl(); } + @Override public String getDbUsername() { return generatorService.getDatabaseUsername(); } + @Override public String generate(List tables, String modelDir, String xmlDir, String mapperDir) throws Exception { + return generatorService.generateByTableNames(tables, true, modelDir, xmlDir, mapperDir); } + @Override public List getDatasourceNames() { return generatorService.getDatasourceNames(); } + @Override public void switchDatasource(String name) { generatorService.switchDatasource(name); } + @Override public String getCurrentDatasourceName() { return generatorService.getCurrentDatasourceName(); } + }; + } + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new MyBatisGeneratorWindow(createCallback(), fileOperationHelper, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new MyBatisGeneratorWindow(createCallback(), fileOperationHelper, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "mybatisgen-close").start(); + } + }; + } + @Override public String checkConfig() { // 1. 优先检查外部配置 @@ -111,7 +165,7 @@ public class MyBatisGeneratorPlugin implements Plugin { // ==================== UI 可视化操作 ==================== - private void openVisualUI() { + public void openVisualUI() { try { JfxBootstrap.await(); } catch (InterruptedException e) { diff --git a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java index 1871b46..9e76acc 100644 --- a/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java +++ b/src/main/java/com/example/workutils/biz/mybatisgenerator/MyBatisGeneratorWindow.java @@ -2,6 +2,8 @@ package com.example.workutils.biz.mybatisgenerator; import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; import javafx.application.Platform; import javafx.collections.FXCollections; import javafx.collections.ObservableList; @@ -10,6 +12,7 @@ import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.geometry.Rectangle2D; +import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.cell.CheckBoxListCell; @@ -109,6 +112,12 @@ public class MyBatisGeneratorWindow { stage.show(); } + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { return stage; } + // ==================== UI 构建 ==================== private void buildUI() { @@ -116,12 +125,13 @@ public class MyBatisGeneratorWindow { stage.initModality(Modality.NONE); stage.setOnCloseRequest(e -> close()); - VBox root = new VBox(10); - root.setPadding(new Insets(15)); - root.setStyle("-fx-background-color: #f5f5f5;"); + VBox root = new VBox(Theme.S2); + root.setPadding(new Insets(Theme.S4)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); Label titleLabel = new Label("MyBatis 逆向工程"); - titleLabel.setFont(Font.font("Microsoft YaHei", fsSection() + 4)); + titleLabel.setFont(Theme.heading(fsSection() + 4)); + titleLabel.setTextFill(Theme.TEXT_PRIMARY); HBox dbBox = createDbInfoSection(); @@ -161,7 +171,6 @@ public class MyBatisGeneratorWindow { btn.setMinWidth(Region.USE_PREF_SIZE); btn.setStyle("-fx-font-size: " + fsButton() + "px; -fx-cursor: hand; " + "-fx-padding: 6 16;"); - // 点击按钮时,将当前树节点路径填充到对应字段 btn.setOnAction(e -> { TreeItem selected = projectTree.getSelectionModel().getSelectedItem(); if (selected != null) { @@ -172,16 +181,17 @@ public class MyBatisGeneratorWindow { } } }); - // 选中/未选中样式切换 fillTargetGroup.selectedToggleProperty().addListener((obs, old, cur) -> { boolean active = (btn == cur); btn.setStyle(active - ? "-fx-background-color: #4a90d9; -fx-text-fill: white; -fx-font-size: " + fsButton() + "px; " - + "-fx-background-radius: 4; -fx-padding: 6 16;" - : "-fx-background-color: #e0e0e0; -fx-text-fill: #333; -fx-font-size: " + fsButton() + "px; " - + "-fx-background-radius: 4; -fx-padding: 6 16;"); + ? "-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";-fx-text-fill: white;" + + "-fx-font-size: " + fsButton() + "px; -fx-background-radius: " + Theme.RADIUS_SM + ";" + + "-fx-padding: 6 16;" + : "-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + ";" + + "-fx-font-size: " + fsButton() + "px; -fx-background-radius: " + Theme.RADIUS_SM + ";" + + "-fx-padding: 6 16;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";-fx-border-width: 1;-fx-border-radius: " + Theme.RADIUS_SM + ";"); }); - // 初始化默认选中样式 btn.fire(); return btn; } @@ -189,11 +199,10 @@ public class MyBatisGeneratorWindow { private boolean refreshingDatasource = false; private HBox createDbInfoSection() { - // 与 SplitPane 相同的比例:左侧 28% = 左面板宽度,右侧 72% GridPane grid = new GridPane(); - grid.setHgap(10); - grid.setPadding(new Insets(8, 12, 8, 12)); - grid.setStyle("-fx-background-color: #e8f0fe; -fx-background-radius: 4;"); + grid.setHgap(Theme.S2); + grid.setPadding(new Insets(Theme.S2, Theme.S3, Theme.S2, Theme.S3)); + grid.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY_LIGHT) + ";-fx-background-radius: " + Theme.RADIUS_MD + ";"); ColumnConstraints colLeft = new ColumnConstraints(); colLeft.setPercentWidth(28); @@ -201,18 +210,19 @@ public class MyBatisGeneratorWindow { colRight.setPercentWidth(72); grid.getColumnConstraints().addAll(colLeft, colRight); - // --- 左侧:数据源选择,宽度与下方左面板对齐 --- Label dsLabel = new Label("数据源:"); dsLabel.setFont(Font.font("Microsoft YaHei", fsBody())); dsLabel.setMinWidth(Region.USE_PREF_SIZE); datasourceCmb = new ComboBox<>(); - datasourceCmb.setStyle("-fx-font-size: " + fsBody() + "px;"); + datasourceCmb.setStyle(Theme.STYLE_COMBO + "-fx-font-size: " + fsBody() + "px;"); HBox.setHgrow(datasourceCmb, Priority.ALWAYS); - Button refreshDsBtn = new Button("刷新"); - refreshDsBtn.setMinWidth(Region.USE_PREF_SIZE); - refreshDsBtn.setStyle("-fx-font-size: " + (fsBody() - 1) + "px; -fx-padding: 2 8;"); + Button refreshDsBtn = StyledButton.small("刷新", Font.font("Microsoft YaHei", fsBody() - 1)); + refreshDsBtn.setOnAction(e -> { + refreshDatasourceList(); + updateDbInfo(); + }); refreshDatasourceList(); datasourceCmb.setOnAction(e -> { @@ -223,15 +233,10 @@ public class MyBatisGeneratorWindow { updateDbInfo(); } }); - refreshDsBtn.setOnAction(e -> { - refreshDatasourceList(); - updateDbInfo(); - }); - HBox leftBox = new HBox(8, dsLabel, datasourceCmb, refreshDsBtn); + HBox leftBox = new HBox(Theme.S2, dsLabel, datasourceCmb, refreshDsBtn); leftBox.setAlignment(Pos.CENTER_LEFT); - // --- 右侧:连接信息,白底灰框 --- dbUrlLabel = new Label(); dbUrlLabel.setFont(Font.font("Consolas", fsBody() - 1)); dbUrlLabel.setMaxWidth(Double.MAX_VALUE); @@ -240,10 +245,11 @@ public class MyBatisGeneratorWindow { dbUserLabel.setFont(Font.font("Microsoft YaHei", fsBody() - 1)); updateDbInfo(); - VBox infoBox = new VBox(3, dbUrlLabel, dbUserLabel); - infoBox.setPadding(new Insets(4, 10, 4, 10)); - infoBox.setStyle("-fx-border-color: #b0c4de; -fx-border-width: 1; -fx-border-radius: 3; " - + "-fx-background-color: #ffffff; -fx-background-radius: 3;"); + VBox infoBox = new VBox(Theme.S1, dbUrlLabel, dbUserLabel); + infoBox.setPadding(new Insets(Theme.S1, Theme.S2, Theme.S1, Theme.S2)); + infoBox.setStyle("-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";" + + "-fx-background-color: white; -fx-background-radius: " + Theme.RADIUS_SM + ";"); infoBox.setAlignment(Pos.CENTER_LEFT); infoBox.setMaxWidth(Double.MAX_VALUE); @@ -285,15 +291,19 @@ public class MyBatisGeneratorWindow { // ==================== 左侧:项目路径树 ==================== private VBox createProjectTreePanel() { - VBox panel = new VBox(8); - panel.setPadding(new Insets(5)); + VBox panel = new VBox(Theme.S2); + panel.setPadding(new Insets(Theme.S1)); Label treeTitle = new Label("项目路径 (快速定位)"); - treeTitle.setFont(Font.font("Microsoft YaHei", fsSection())); + treeTitle.setFont(Theme.heading(fsSection())); + treeTitle.setTextFill(Theme.TEXT_PRIMARY); projectTree = new TreeView<>(); projectTree.setRoot(new TreeItem<>("项目列表")); projectTree.setShowRoot(false); + projectTree.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); projectTree.setCellFactory(tv -> new TreeCell() { @Override protected void updateItem(String item, boolean empty) { @@ -311,7 +321,6 @@ public class MyBatisGeneratorWindow { loadProjectTree(); VBox.setVgrow(projectTree, Priority.ALWAYS); - panel.getChildren().addAll(treeTitle, projectTree); return panel; } @@ -390,30 +399,32 @@ public class MyBatisGeneratorWindow { // ==================== 右侧:表单 ==================== private VBox createFormPanel() { - VBox panel = new VBox(10); - panel.setPadding(new Insets(5)); + VBox panel = new VBox(Theme.S2); + panel.setPadding(new Insets(Theme.S1)); Label patternLabel = new Label("表名通配符:"); patternLabel.setFont(Font.font("Microsoft YaHei", fsLabel())); - HBox patternBox = new HBox(10); + patternLabel.setTextFill(Theme.TEXT_PRIMARY); + HBox patternBox = new HBox(Theme.S2); patternField = new TextField(); patternField.setPromptText("如 trade%"); patternField.setPrefWidth(200); - Button queryBtn = new Button("查询表"); - queryBtn.setStyle("-fx-background-color: #4a90d9; -fx-text-fill: white; -fx-font-size: " + fsButton() + "px;"); + patternField.setStyle(Theme.STYLE_TEXT_FIELD); + Button queryBtn = StyledButton.primary("查询表", Font.font("Microsoft YaHei", fsButton())); queryBtn.setOnAction(e -> onQueryTables()); patternBox.getChildren().addAll(patternField, queryBtn); Label tableLabel = new Label("匹配表 (勾选要生成的表):"); tableLabel.setFont(Font.font("Microsoft YaHei", fsLabel())); + tableLabel.setTextFill(Theme.TEXT_PRIMARY); tableListView = new ListView<>(tableItems); tableListView.setPrefHeight(170); tableListView.setCellFactory(CheckBoxListCell.forListView(TableItem::selectedProperty)); - HBox selectBox = new HBox(10); - Button selectAllBtn = new Button("全选"); + HBox selectBox = new HBox(Theme.S2); + Button selectAllBtn = StyledButton.small("全选", Font.font("Microsoft YaHei", fsBody() - 1)); selectAllBtn.setOnAction(e -> tableItems.forEach(t -> t.setSelected(true))); - Button deselectAllBtn = new Button("取消全选"); + Button deselectAllBtn = StyledButton.small("取消全选", Font.font("Microsoft YaHei", fsBody() - 1)); deselectAllBtn.setOnAction(e -> tableItems.forEach(t -> t.setSelected(false))); selectBox.getChildren().addAll(selectAllBtn, deselectAllBtn); @@ -425,12 +436,12 @@ public class MyBatisGeneratorWindow { } private VBox createDirectorySection() { - VBox box = new VBox(8); - box.setPadding(new Insets(10, 0, 0, 0)); + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S2, 0, 0, 0)); - // 标题行:左侧"输出目录设置",右侧快速填充按钮 Label dirLabel = new Label("输出目录设置:"); - dirLabel.setFont(Font.font("Microsoft YaHei", fsSection())); + dirLabel.setFont(Theme.heading(fsSection())); + dirLabel.setTextFill(Theme.TEXT_PRIMARY); this.fillTargetGroup = new ToggleGroup(); @@ -446,7 +457,7 @@ public class MyBatisGeneratorWindow { Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); - HBox headerRow = new HBox(10, dirLabel, spacer, quickLabel, btnModel, btnXml, btnMapper); + HBox headerRow = new HBox(Theme.S2, dirLabel, spacer, quickLabel, btnModel, btnXml, btnMapper); headerRow.setAlignment(Pos.CENTER_LEFT); HBox modelRow = createDirRow("Model (PO)", fm -> modelDirField = fm); @@ -463,7 +474,7 @@ public class MyBatisGeneratorWindow { } private HBox createDirRow(String label, java.util.function.Consumer fieldRef) { - HBox row = new HBox(8); + HBox row = new HBox(Theme.S2); row.setAlignment(Pos.CENTER_LEFT); Label lbl = new Label(label + ":"); @@ -471,10 +482,10 @@ public class MyBatisGeneratorWindow { lbl.setFont(Font.font("Microsoft YaHei", fsLabel())); TextField field = new TextField(); + field.setStyle(Theme.STYLE_TEXT_FIELD); HBox.setHgrow(field, Priority.ALWAYS); - Button browseBtn = new Button("浏览..."); - browseBtn.setMinWidth(60); + Button browseBtn = StyledButton.small("浏览...", Font.font("Microsoft YaHei", fsBody() - 1)); browseBtn.setOnAction(e -> onBrowseDir(field)); if (fieldRef != null) fieldRef.accept(field); @@ -597,18 +608,16 @@ public class MyBatisGeneratorWindow { // ---- 底部按钮栏 ---- private HBox createButtonBar() { - HBox box = new HBox(15); + HBox box = new HBox(Theme.S4); box.setAlignment(Pos.CENTER_RIGHT); - box.setPadding(new Insets(10, 0, 0, 0)); + box.setPadding(new Insets(Theme.S2, 0, 0, 0)); - Button generateBtn = new Button("生成"); + Button generateBtn = StyledButton.primary("生成", Font.font("Microsoft YaHei", fsButton() + 1)); generateBtn.setPrefWidth(100); - generateBtn.setStyle("-fx-background-color: #4caf50; -fx-text-fill: white; -fx-font-size: " + (fsButton() + 1) + "px;"); generateBtn.setOnAction(e -> onGenerate()); - Button cancelBtn = new Button("取消"); + Button cancelBtn = StyledButton.secondary("取消", Font.font("Microsoft YaHei", fsButton())); cancelBtn.setPrefWidth(80); - cancelBtn.setStyle("-fx-font-size: " + fsButton() + "px;"); cancelBtn.setOnAction(e -> close()); box.getChildren().addAll(generateBtn, cancelBtn); @@ -639,14 +648,15 @@ public class MyBatisGeneratorWindow { textArea.setEditable(false); textArea.setWrapText(true); textArea.setFont(Font.font("Consolas", properties.getFontMono())); + textArea.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); - Button closeBtn = new Button("关闭"); + Button closeBtn = StyledButton.secondary("关闭", Theme.base(14)); closeBtn.setOnAction(e -> dialog.close()); HBox btnBar = new HBox(closeBtn); btnBar.setAlignment(Pos.CENTER_RIGHT); - btnBar.setPadding(new Insets(8)); - btnBar.setStyle("-fx-background-color: #f0f0f0;"); + btnBar.setPadding(new Insets(Theme.S2)); + btnBar.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_SURFACE) + ";"); VBox root = new VBox(textArea, btnBar); VBox.setVgrow(textArea, Priority.ALWAYS); diff --git a/src/main/java/com/example/workutils/biz/scanner/ScannerPlugin.java b/src/main/java/com/example/workutils/biz/scanner/ScannerPlugin.java index 30dfbce..ea7f92c 100644 --- a/src/main/java/com/example/workutils/biz/scanner/ScannerPlugin.java +++ b/src/main/java/com/example/workutils/biz/scanner/ScannerPlugin.java @@ -1,13 +1,20 @@ package com.example.workutils.biz.scanner; import com.example.workutils.biz.scanner.service.ScannerService; +import com.example.workutils.system.config.WorkUtilsProperties; import com.example.workutils.system.plugin.PluginDescriptor; import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.VisualPluginFactory; +import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.support.OutputHelper; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.Scanner; +import java.util.concurrent.CountDownLatch; /** * 扫描工具插件(实现 Tool 接口) @@ -23,6 +30,7 @@ public class ScannerPlugin implements Plugin { .version("1.0.0") .description("关键词/Redis/Dubbo扫描工具") .order(40) + .icon("🔍") .build(); private static final String[] MENU_OPTIONS = { @@ -31,6 +39,9 @@ public class ScannerPlugin implements Plugin { }; private final ScannerService scannerService; + private final FileOperationHelper fileOperationHelper; + private final OutputHelper outputHelper; + private final WorkUtilsProperties properties; @Override public PluginDescriptor getDescriptor() { @@ -47,6 +58,44 @@ public class ScannerPlugin implements Plugin { return MENU_OPTIONS; } + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private ScannerWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) return embeddedRoot; + latch = new CountDownLatch(1); + window = new ScannerWindow(scannerService, fileOperationHelper, + outputHelper, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new ScannerWindow(scannerService, fileOperationHelper, + outputHelper, properties, latch); + } + // 如果之前被内嵌过,Scene root 已被掏空,需要把 embeddedRoot 挂回去 + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { latch.await(); } catch (InterruptedException ignored) {} + onClosed.run(); + }, "scan-close").start(); + } + }; + } + @Override public void executeCommand(String... args) throws Exception { if (args.length < 2) { diff --git a/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java b/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java new file mode 100644 index 0000000..90ab7f1 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/scanner/ScannerWindow.java @@ -0,0 +1,458 @@ +package com.example.workutils.biz.scanner; + +import com.example.workutils.biz.scanner.service.ScannerService; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.FileOperationHelper; +import com.example.workutils.system.support.HistoryPersistence; +import com.example.workutils.system.support.OutputHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.layout.*; +import javafx.scene.Parent; +import javafx.stage.Stage; + +import java.awt.Desktop; +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +/** + * 代码扫描可视化窗口 + *

+ * 支持关键词/Redis/Dubbo 三种扫描模式,结果自动导出CSV,历史记录可追溯打开 + */ +public class ScannerWindow { + + private final Stage stage; + private final CountDownLatch closeLatch; + private final ScannerService scannerService; + private final WorkUtilsProperties properties; + private final OutputHelper outputHelper; + + // UI + private CheckBox regexCb; + private TextField patternField; + private Button scanBtn; + private ProgressBar progressBar; + private Label progressLabel; + private TextArea logArea; + private TableView historyTable; + private final ObservableList historyRows = FXCollections.observableArrayList(); + + private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); + private int historyCounter = 0; + private final HistoryPersistence historyPersistence; + private static final String DELIMITER = "|"; + + public ScannerWindow(ScannerService scannerService, FileOperationHelper fileOperationHelper, + OutputHelper outputHelper, WorkUtilsProperties properties, + CountDownLatch closeLatch) { + this.scannerService = scannerService; + this.outputHelper = outputHelper; + this.properties = properties; + this.closeLatch = closeLatch; + this.historyPersistence = new HistoryPersistence("scanner_history.txt"); + this.stage = new Stage(); + buildUI(); + loadHistory(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { return stage; } + + private int fsSection() { return properties.getFontSectionTitle(); } + private int fsBtn() { return properties.getFontButton(); } + private int fsBody() { return properties.getFontBody(); } + + private void buildUI() { + stage.setTitle("代码扫描"); + stage.setWidth(980); + stage.setHeight(700); + stage.setMinWidth(820); + stage.setMinHeight(520); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + VBox root = new VBox(Theme.S3); + root.setPadding(new Insets(Theme.S4)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + // === 顶部配置卡片(两行布局) === + VBox configCard = new VBox(Theme.S2); + configCard.setPadding(new Insets(Theme.S3)); + configCard.setStyle(Theme.STYLE_CARD); + + // 第一行:扫描类型 + 正则选项 + HBox typeRow = new HBox(Theme.S3 * 2); + typeRow.setAlignment(Pos.CENTER_LEFT); + + Label typeLabel = new Label("扫描类型:关键词扫描"); + typeLabel.setFont(Theme.bold(fsBody())); + typeLabel.setTextFill(Theme.TEXT_PRIMARY); + + regexCb = new CheckBox("正则匹配"); + regexCb.setFont(Theme.base(fsBody())); + regexCb.setTextFill(Theme.TEXT_PRIMARY); + + typeRow.getChildren().addAll(typeLabel, regexCb); + + // 第二行:匹配模式输入 + 按钮 + HBox patternRow = new HBox(Theme.S3); + patternRow.setAlignment(Pos.CENTER_LEFT); + + Label patternLabel = new Label("匹配模式:"); + patternLabel.setFont(Theme.bold(fsBody())); + patternLabel.setTextFill(Theme.TEXT_PRIMARY); + patternField = new TextField(); + patternField.setFont(Theme.base(fsBody())); + patternField.setStyle(Theme.STYLE_TEXT_FIELD); + patternField.setPromptText("输入关键词或正则表达式"); + HBox.setHgrow(patternField, Priority.ALWAYS); + + scanBtn = StyledButton.primary("开始扫描", Theme.bold(fsBtn())); + scanBtn.setOnAction(e -> startScan()); + + patternRow.getChildren().addAll(patternLabel, patternField, scanBtn); + + configCard.getChildren().addAll(typeRow, patternRow); + + // === 进度行 === + HBox progressRow = new HBox(Theme.S2); + progressRow.setAlignment(Pos.CENTER_LEFT); + progressBar = new ProgressBar(0); + progressBar.setPrefWidth(400); + progressBar.setPrefHeight(22); + progressLabel = new Label("就绪"); + progressLabel.setFont(Theme.base(fsBody() - 1)); + progressLabel.setTextFill(Theme.TEXT_SECONDARY); + progressRow.getChildren().addAll(progressBar, progressLabel); + + // === 标题 === + Label tableTitle = new Label("扫描导出历史"); + tableTitle.setFont(Theme.heading(fsSection())); + tableTitle.setTextFill(Theme.TEXT_PRIMARY); + + // === 历史记录表格 === + historyTable = new TableView<>(); + historyTable.setItems(historyRows); + historyTable.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + historyTable.setPlaceholder(new Label("暂无扫描记录")); + historyTable.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + + // 列定义 + TableColumn colIndex = new TableColumn<>("序号"); + colIndex.setCellValueFactory(new PropertyValueFactory<>("index")); + + TableColumn colQuery = new TableColumn<>("查询条件"); + colQuery.setCellValueFactory(new PropertyValueFactory<>("queryCondition")); + + TableColumn colFile = new TableColumn<>("生成文件"); + colFile.setCellValueFactory(new PropertyValueFactory<>("fileDisplay")); + + TableColumn colCount = new TableColumn<>("匹配数"); + colCount.setCellValueFactory(new PropertyValueFactory<>("matchCount")); + + TableColumn colTime = new TableColumn<>("时间"); + colTime.setCellValueFactory(new PropertyValueFactory<>("timestamp")); + + TableColumn colAction = new TableColumn<>("操作"); + colAction.setCellValueFactory(new PropertyValueFactory<>("actionLabel")); + colAction.setCellFactory(col -> buildActionCell()); + + historyTable.getColumns().addAll(colIndex, colQuery, colFile, colCount, colTime, colAction); + + // 列宽百分比自适应:序号4% 查询28% 文件26% 匹配7% 时间17% 操作18% + colIndex.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.04)); + colQuery.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.28)); + colFile.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.26)); + colCount.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.07)); + colTime.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.17)); + colAction.prefWidthProperty().bind(historyTable.widthProperty().subtract(18).multiply(0.18)); + + VBox.setVgrow(historyTable, Priority.ALWAYS); + + // === 日志区 === + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setPrefRowCount(5); + logArea.setFont(Theme.mono(properties.getFontMono())); + logArea.setStyle(Theme.STYLE_LOG_AREA); + + root.getChildren().addAll(configCard, progressRow, tableTitle, historyTable, logArea); + stage.setScene(new Scene(root)); + } + + /** + * 构建操作列单元格,包含「打开」按钮和右键菜单 + */ + private TableCell buildActionCell() { + TableCell cell = new TableCell() { + private final Button openBtn = new Button("打开文件"); + private final Button dirBtn = new Button("打开目录"); + private final HBox btnBox = new HBox(4, openBtn, dirBtn); + private final ContextMenu menu = new ContextMenu(); + private String currentFileName; + + { + openBtn.setFont(Theme.base(fsBody() - 2)); + openBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";" + + "-fx-text-fill: white; -fx-cursor: hand;" + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 8;"); + + dirBtn.setFont(Theme.base(fsBody() - 2)); + dirBtn.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + "; -fx-cursor: hand;" + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-padding: 3 6;"); + dirBtn.setTooltip(new Tooltip("打开文件所在目录")); + + // 右键菜单选项 + MenuItem defaultOpen = new MenuItem("系统默认程序打开"); + defaultOpen.setOnAction(e -> openFile(currentFileName)); + MenuItem openNotepad = new MenuItem("用记事本打开"); + openNotepad.setOnAction(e -> openWithNotepad(currentFileName)); + MenuItem openFolder = new MenuItem("打开所在文件夹"); + openFolder.setOnAction(e -> openFolder(currentFileName)); + MenuItem copyPath = new MenuItem("复制文件路径"); + copyPath.setOnAction(e -> copyFilePath(currentFileName)); + menu.getItems().addAll(defaultOpen, openNotepad, openFolder, copyPath); + + // 左键「打开」→ 系统默认程序打开 + openBtn.setOnAction(e -> openFile(currentFileName)); + // 左键「📁」→ 打开所在目录 + dirBtn.setOnAction(e -> openFolder(currentFileName)); + // 右键点击 → 弹出菜单 + btnBox.setOnContextMenuRequested(e -> { + menu.show(btnBox, e.getScreenX(), e.getScreenY()); + }); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty) { + setGraphic(null); + currentFileName = null; + } else { + // 从表格数据中获取当前行并缓存文件路径 + int idx = getIndex(); + if (idx >= 0 && getTableView() != null) { + HistoryRow row = getTableView().getItems().get(idx); + currentFileName = row != null ? row.getFileName() : null; + } + setGraphic(btnBox); + } + } + + private void copyFilePath(String path) { + if (path == null) return; + javafx.scene.input.Clipboard.getSystemClipboard() + .setContent(new javafx.scene.input.ClipboardContent() {{ + putString(path); + }}); + logArea.appendText("[INFO] 已复制路径: " + path + "\n"); + } + }; + return cell; + } + + // ==================== 历史持久化 ==================== + + /** + * 从 data/scanner_history.txt 加载历史记录,新记录在前 + */ + private void loadHistory() { + List lines = historyPersistence.load(); + int hc = 0; + for (String line : lines) { + String[] parts = line.split("\\" + DELIMITER, 4); + if (parts.length == 4) { + hc++; + historyRows.add(new HistoryRow( + String.valueOf(hc), parts[0], parts[1], + Integer.parseInt(parts[2]), parts[3], "打开")); + } + } + historyCounter = hc; + } + + /** + * 将当前历史保存到 data/scanner_history.txt,最新在前 + */ + private void saveHistory() { + List lines = new ArrayList<>(); + for (HistoryRow row : historyRows) { + lines.add(row.getQueryCondition() + DELIMITER + + row.getFileName() + DELIMITER + + row.getMatchCount() + DELIMITER + + row.getTimestamp()); + } + historyPersistence.save(lines); + } + + private void startScan() { + String pattern = patternField.getText().trim(); + if (pattern.isEmpty()) { + logArea.appendText("[WARN] 请输入匹配模式\n"); + return; + } + + boolean isRegex = regexCb.isSelected(); + String modeLabel = isRegex ? "正则" : "关键词"; + + scanBtn.setDisable(true); + progressBar.setProgress(ProgressBar.INDETERMINATE_PROGRESS); + progressLabel.setText("正在扫描..."); + logArea.clear(); + logArea.appendText("[" + modeLabel + "] 模式: " + pattern + "\n"); + + new Thread(() -> { + try { + ScannerService.ScanResult result = scannerService.scanAllProjects(ScannerService.ScanType.KEYWORD, pattern); + int totalMatches = result.getTotalMatches(); + + // 自动导出到 CSV + String fileName = outputHelper.generateOutputFileName("scan_result", "csv"); + scannerService.exportScanResults(result, fileName); + + int hc = ++historyCounter; + String timestamp = SDF.format(new Date()); + String queryCond = modeLabel + "扫描: " + pattern; + + Platform.runLater(() -> { + progressBar.setProgress(1); + progressLabel.setText("扫描完成"); + scanBtn.setDisable(false); + + // 添加历史记录行(最新在前) + historyRows.add(0, new HistoryRow( + String.valueOf(hc), queryCond, fileName, + totalMatches, timestamp, "打开")); + saveHistory(); + + logArea.appendText(String.format("扫描完成: %d 个项目, %d 条匹配\n", + result.getProjectResults().size(), totalMatches)); + logArea.appendText("已导出: " + fileName + "\n"); + }); + + } catch (Exception ex) { + Platform.runLater(() -> { + progressBar.setProgress(1); + progressLabel.setText("扫描失败"); + logArea.appendText("[ERROR] " + ex.getMessage() + "\n"); + scanBtn.setDisable(false); + }); + } + }, "Scanner-Worker").start(); + } + + // ==================== 文件打开工具方法 ==================== + + private void openFile(String path) { + if (path == null) { + logArea.appendText("[WARN] 路径为空\n"); + return; + } + try { + File f = new File(path); + if (!f.exists()) { + logArea.appendText("[WARN] 文件不存在: " + path + "\n"); + return; + } + // Windows 用 cmd /c start 更可靠 + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + new ProcessBuilder("cmd", "/c", "start", "", path).start(); + } else { + Desktop.getDesktop().open(f); + } + logArea.appendText("[OK] 已打开: " + path + "\n"); + } catch (Exception e) { + logArea.appendText("[ERROR] 打开失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n"); + } + } + + private void openWithNotepad(String path) { + try { + new ProcessBuilder("notepad.exe", path).start(); + logArea.appendText("[OK] 用记事本打开: " + path + "\n"); + } catch (Exception e) { + openFile(path); // fallback + } + } + + private void openFolder(String path) { + if (path == null) { + logArea.appendText("[WARN] 路径为空\n"); + return; + } + try { + File f = new File(path).getParentFile(); + if (f == null || !f.exists()) { + logArea.appendText("[WARN] 目录不存在: " + path + "\n"); + return; + } + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + new ProcessBuilder("explorer.exe", "/select,", path).start(); + } else { + Desktop.getDesktop().open(f); + } + logArea.appendText("[OK] 已打开目录: " + f.getAbsolutePath() + "\n"); + } catch (Exception e) { + logArea.appendText("[ERROR] 打开目录失败: " + (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()) + "\n"); + } + } + + // ==================== 数据行 ==================== + + public static class HistoryRow { + private final String index; + private final String queryCondition; + private final String fileName; + private final int matchCount; + private final String timestamp; + private final String actionLabel; + + HistoryRow(String index, String queryCondition, String fileName, + int matchCount, String timestamp, String actionLabel) { + this.index = index; + this.queryCondition = queryCondition; + this.fileName = fileName; + this.matchCount = matchCount; + this.timestamp = timestamp; + this.actionLabel = actionLabel; + } + + public String getIndex() { return index; } + public String getQueryCondition() { return queryCondition; } + public String getFileName() { return fileName; } + // 文件路径仅显示文件名 + public String getFileDisplay() { + return fileName.contains("\\") || fileName.contains("/") + ? fileName.substring(Math.max(fileName.lastIndexOf('\\'), fileName.lastIndexOf('/')) + 1) + : fileName; + } + public String getMatchCount() { return String.valueOf(matchCount); } + public String getTimestamp() { return timestamp; } + public String getActionLabel() { return actionLabel; } + } +} diff --git a/src/main/java/com/example/workutils/system/config/SettingsService.java b/src/main/java/com/example/workutils/system/config/SettingsService.java index 8ddf959..2a3c429 100644 --- a/src/main/java/com/example/workutils/system/config/SettingsService.java +++ b/src/main/java/com/example/workutils/system/config/SettingsService.java @@ -24,6 +24,13 @@ public class SettingsService { private final ExternalConfigManager configManager; private final DatasourceConfigManager datasourceConfigManager; + /** + * 创建设置窗口(不自动 showAndWait),供内嵌模式使用 + */ + public SettingsWindow createSettingsWindow(Runnable onConfigChanged) { + return new SettingsWindow(properties, configManager, datasourceConfigManager, onConfigChanged); + } + /** * 打开系统设置 GUI 窗口 * @param scanner 控制台 Scanner(GUI 模式不需要) diff --git a/src/main/java/com/example/workutils/system/config/SettingsWindow.java b/src/main/java/com/example/workutils/system/config/SettingsWindow.java index a74aac0..50d30cd 100644 --- a/src/main/java/com/example/workutils/system/config/SettingsWindow.java +++ b/src/main/java/com/example/workutils/system/config/SettingsWindow.java @@ -1,10 +1,12 @@ package com.example.workutils.system.config; import com.example.workutils.system.support.JfxBootstrap; +import com.example.workutils.system.ui.Theme; import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.geometry.Rectangle2D; +import javafx.scene.Parent; import javafx.scene.Scene; import javafx.scene.control.*; import javafx.scene.control.Alert.AlertType; @@ -83,6 +85,24 @@ public class SettingsWindow { this.fontMono = properties.getFontMono(); } + public Parent getContentPane() { + if (stage == null) { + stage = new Stage(); + stage.setTitle("系统设置"); + stage.setOnCloseRequest(e -> { /* no-op when embedded */ }); + // 使用与 createScene 一致的布局 + TabPane tabPane = createTabPaneRoot(); + VBox root = new VBox(Theme.S2); + root.setPadding(new Insets(Theme.S3)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + root.getChildren().add(tabPane); + stage.setScene(new Scene(root, screenSize().getWidth() * 0.75, screenSize().getHeight() * 0.75)); + } + return stage.getScene().getRoot(); + } + + public Stage getStage() { return stage; } + public void showAndWait() throws InterruptedException { JfxBootstrap.await(); if (JfxBootstrap.isFailed()) { @@ -112,7 +132,7 @@ public class SettingsWindow { }); } - private Scene createScene(Rectangle2D screen) { + private TabPane createTabPaneRoot() { TabPane tabPane = new TabPane(); tabPane.getTabs().addAll( createPathTab(), @@ -123,9 +143,15 @@ public class SettingsWindow { createDisplayTab() ); tabPane.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + return tabPane; + } - VBox root = new VBox(10); - root.setPadding(new Insets(12)); + private Scene createScene(Rectangle2D screen) { + TabPane tabPane = createTabPaneRoot(); + + VBox root = new VBox(Theme.S2); + root.setPadding(new Insets(Theme.S3)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); root.getChildren().add(tabPane); return new Scene(root, screen.getWidth() * 0.75, screen.getHeight() * 0.75); @@ -155,21 +181,21 @@ public class SettingsWindow { // 文件路径 Label pathLabel = new Label(filePath.toAbsolutePath().toString()); pathLabel.setStyle("-fx-font-family: 'Consolas'; -fx-font-size: " + fontMono + "px; " - + "-fx-background-color: #f5f5f5; -fx-padding: 8; -fx-background-radius: 4;"); + + "-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";-fx-padding: 8; -fx-background-radius: " + Theme.RADIUS_SM + ";"); pathLabel.setWrapText(true); pathLabel.setMaxWidth(Double.MAX_VALUE); box.getChildren().add(pathLabel); if (!Files.exists(filePath)) { Label notExist = new Label("⚠ 文件尚未创建,点击下方按钮将自动创建空文件。"); - notExist.setStyle("-fx-text-fill: #e67e22; -fx-font-size: " + fontBody + "px;"); + notExist.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.WARNING) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(notExist); box.getChildren().add(notExist); } if (isReadOnly) { Label readOnly = new Label("⚠ 此文件为只读记录,由系统自动维护。"); - readOnly.setStyle("-fx-text-fill: #c0392b; -fx-font-size: " + fontBody + "px;"); + readOnly.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.DANGER) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(readOnly); box.getChildren().add(readOnly); } @@ -180,7 +206,8 @@ public class SettingsWindow { Button btnOpen = new Button(isReadOnly ? "用记事本查看" : "用记事本打开"); btnOpen.setFont(font(fontButton)); - btnOpen.setStyle("-fx-padding: 10 20; -fx-background-radius: 4; -fx-cursor: hand;"); + btnOpen.setStyle("-fx-padding: 10 20; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";-fx-text-fill: white;"); allButtons.add(btnOpen); btnOpen.setOnAction(e -> openFileInEditor(filePath)); btnRow.getChildren().add(btnOpen); @@ -188,7 +215,9 @@ public class SettingsWindow { if (onReload != null) { Button btnReload = new Button("重新加载"); btnReload.setFont(font(fontButton)); - btnReload.setStyle("-fx-padding: 10 20; -fx-background-radius: 4; -fx-cursor: hand;"); + btnReload.setStyle("-fx-padding: 10 20; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + ";" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";-fx-border-width: 1;-fx-border-radius: " + Theme.RADIUS_SM + ";"); allButtons.add(btnReload); btnReload.setOnAction(e -> { onReload.run(); @@ -207,7 +236,7 @@ public class SettingsWindow { hint = hint + "\n" + extraHint; } Label hintLabel = new Label(hint); - hintLabel.setStyle("-fx-text-fill: #666; -fx-font-size: " + fontBody + "px;"); + hintLabel.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.TEXT_SECONDARY) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(hintLabel); box.getChildren().add(hintLabel); @@ -262,7 +291,7 @@ public class SettingsWindow { // 文件路径 Label pathLabel = new Label(filePath.toAbsolutePath().toString()); pathLabel.setStyle("-fx-font-family: 'Consolas'; -fx-font-size: " + fontMono + "px; " - + "-fx-background-color: #f5f5f5; -fx-padding: 8; -fx-background-radius: 4;"); + + "-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";-fx-padding: 8; -fx-background-radius: " + Theme.RADIUS_SM + ";"); pathLabel.setWrapText(true); pathLabel.setMaxWidth(Double.MAX_VALUE); box.getChildren().add(pathLabel); @@ -273,14 +302,17 @@ public class SettingsWindow { Button btnOpen = new Button("用记事本打开"); btnOpen.setFont(font(fontButton)); - btnOpen.setStyle("-fx-padding: 10 20; -fx-background-radius: 4; -fx-cursor: hand;"); + btnOpen.setStyle("-fx-padding: 10 20; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";-fx-text-fill: white;"); allButtons.add(btnOpen); btnOpen.setOnAction(e -> openFileInEditor(filePath)); btnRow.getChildren().add(btnOpen); Button btnReload = new Button("重新加载"); btnReload.setFont(font(fontButton)); - btnReload.setStyle("-fx-padding: 10 20; -fx-background-radius: 4; -fx-cursor: hand;"); + btnReload.setStyle("-fx-padding: 10 20; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";-fx-text-fill: " + Theme.toWeb(Theme.TEXT_PRIMARY) + ";" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";-fx-border-width: 1;-fx-border-radius: " + Theme.RADIUS_SM + ";"); allButtons.add(btnReload); btnReload.setOnAction(e -> { reloadProjectPathsPreview(); @@ -292,7 +324,7 @@ public class SettingsWindow { // 提示 Label hint = new Label("用外部编辑器编辑 project_paths.txt 后,点击「重新加载」使配置生效。"); - hint.setStyle("-fx-text-fill: #666; -fx-font-size: " + fontBody + "px;"); + hint.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.TEXT_SECONDARY) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(hint); box.getChildren().add(hint); @@ -344,15 +376,15 @@ public class SettingsWindow { if (!Files.exists(filePath)) { Label notExist = new Label("⚠ 缓存文件为空,暂无损坏记录。"); - notExist.setStyle("-fx-text-fill: #e67e22; -fx-font-size: " + fontBody + "px;"); + notExist.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.WARNING) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(notExist); box.getChildren().add(notExist); } - Label readOnly = new Label("⚠ 此文件由系统自动维护,记录同步过程中损坏的 Git 仓库。"); - readOnly.setStyle("-fx-text-fill: #c0392b; -fx-font-size: " + fontBody + "px;"); - hintLabels.add(readOnly); - box.getChildren().add(readOnly); + Label readOnlyDmg = new Label("⚠ 此文件由系统自动维护,记录同步过程中损坏的 Git 仓库。"); + readOnlyDmg.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.DANGER) + ";-fx-font-size: " + fontBody + "px;"); + hintLabels.add(readOnlyDmg); + box.getChildren().add(readOnlyDmg); // 按钮行 HBox btnRow = new HBox(10); @@ -360,15 +392,16 @@ public class SettingsWindow { Button btnView = new Button("用记事本查看"); btnView.setFont(font(fontButton)); - btnView.setStyle("-fx-padding: 10 20; -fx-background-radius: 4; -fx-cursor: hand;"); + btnView.setStyle("-fx-padding: 10 20; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.PRIMARY) + ";-fx-text-fill: white;"); allButtons.add(btnView); btnView.setOnAction(e -> openFileInEditor(filePath)); btnRow.getChildren().add(btnView); Button btnClear = new Button("清理损坏缓存"); btnClear.setFont(font(fontButton)); - btnClear.setStyle("-fx-text-fill: #c0392b; -fx-padding: 10 20; " - + "-fx-background-radius: 4; -fx-cursor: hand;"); + btnClear.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.DANGER) + ";-fx-padding: 10 20; " + + "-fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;"); allButtons.add(btnClear); btnClear.setOnAction(e -> { Alert confirm = new Alert(AlertType.CONFIRMATION); @@ -391,10 +424,10 @@ public class SettingsWindow { box.getChildren().add(btnRow); // 提示 - Label hint = new Label("此文件由系统自动维护。清除后下次同步时将重新扫描所有仓库。"); - hint.setStyle("-fx-text-fill: #666; -fx-font-size: " + fontBody + "px;"); - hintLabels.add(hint); - box.getChildren().add(hint); + Label hintDmg = new Label("此文件由系统自动维护。清除后下次同步时将重新扫描所有仓库。"); + hintDmg.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.TEXT_SECONDARY) + ";-fx-font-size: " + fontBody + "px;"); + hintLabels.add(hintDmg); + box.getChildren().add(hintDmg); Tab tab = new Tab("损坏缓存"); tab.setContent(box); @@ -415,7 +448,7 @@ public class SettingsWindow { root.getChildren().add(title); Label note = new Label("可调范围 16px – 32px,点击「保存字体设置」后写入 workutils.config 并全局生效"); - note.setStyle("-fx-text-fill: #666; -fx-font-size: " + fontBody + "px;"); + note.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.TEXT_SECONDARY) + ";-fx-font-size: " + fontBody + "px;"); hintLabels.add(note); root.getChildren().add(note); @@ -436,7 +469,8 @@ public class SettingsWindow { Button btnSaveFont = new Button("保存字体设置"); btnSaveFont.setFont(font(fontButton)); - btnSaveFont.setStyle("-fx-padding: 10 24; -fx-background-radius: 4; -fx-cursor: hand;"); + btnSaveFont.setStyle("-fx-padding: 10 24; -fx-background-radius: " + Theme.RADIUS_SM + "; -fx-cursor: hand;" + + "-fx-background-color: " + Theme.toWeb(Theme.SUCCESS) + ";-fx-text-fill: white;"); allButtons.add(btnSaveFont); btnSaveFont.setOnAction(e -> saveFontSettings()); root.getChildren().add(btnSaveFont); @@ -554,7 +588,7 @@ public class SettingsWindow { private VBox createReplaceTool() { VBox box = new VBox(8); box.setPadding(new Insets(8, 0, 0, 0)); - box.setStyle("-fx-border-color: #e0e0e0; -fx-border-width: 1 0 0 0; -fx-padding: 10 0 0 0;"); + box.setStyle("-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";-fx-border-width: 1 0 0 0; -fx-padding: 10 0 0 0;"); Label title = new Label("批量字符替换"); title.setStyle("-fx-font-weight: bold; -fx-font-size: " + fontSectionTitle + "px;"); @@ -838,7 +872,7 @@ public class SettingsWindow { Label valLabel = new Label(String.valueOf(initialValue) + "px"); valLabel.setPrefWidth(40); valLabel.setFont(font(14)); - valLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: #4a90d9;"); + valLabel.setStyle("-fx-font-weight: bold; -fx-text-fill: " + Theme.toWeb(Theme.PRIMARY) + ";"); Label preview = new Label("预览 Abc 123"); preview.setFont(font(initialValue)); @@ -846,7 +880,7 @@ public class SettingsWindow { preview.setStyle("-fx-font-weight: bold;"); titleLabels.add(preview); } else if (description.contains("提示")) { - preview.setStyle("-fx-text-fill: #666;"); + preview.setStyle("-fx-text-fill: " + Theme.toWeb(Theme.TEXT_SECONDARY) + ";"); hintLabels.add(preview); } else if (description.contains("等宽")) { preview.setFont(Font.font("Consolas", initialValue)); diff --git a/src/main/java/com/example/workutils/system/plugin/Plugin.java b/src/main/java/com/example/workutils/system/plugin/Plugin.java index ccad174..1f9f70c 100644 --- a/src/main/java/com/example/workutils/system/plugin/Plugin.java +++ b/src/main/java/com/example/workutils/system/plugin/Plugin.java @@ -91,6 +91,16 @@ public interface Plugin { return null; } + // ==================== 可视化窗口 ==================== + + /** + * 返回可视化窗口工厂。返回 null 表示该插件不支持可视窗口, + * 主窗口中将显示为灰色禁用态。 + */ + default VisualPluginFactory getVisualFactory() { + return null; + } + // ==================== 控制台输出辅助方法 ==================== default void printMenuTitle(String title) { diff --git a/src/main/java/com/example/workutils/system/plugin/PluginDescriptor.java b/src/main/java/com/example/workutils/system/plugin/PluginDescriptor.java index 8effbe2..e90bac6 100644 --- a/src/main/java/com/example/workutils/system/plugin/PluginDescriptor.java +++ b/src/main/java/com/example/workutils/system/plugin/PluginDescriptor.java @@ -17,6 +17,7 @@ public class PluginDescriptor { private final String description; private final List dependencies; private final int order; + private final String icon; private PluginDescriptor(Builder builder) { this.id = builder.id; @@ -25,6 +26,7 @@ public class PluginDescriptor { this.description = builder.description; this.dependencies = Collections.unmodifiableList(builder.dependencies); this.order = builder.order; + this.icon = builder.icon; } public static Builder builder() { @@ -38,6 +40,7 @@ public class PluginDescriptor { public String getVersion() { return version; } public String getDescription() { return description; } public int getOrder() { return order; } + public String getIcon() { return icon; } /** 依赖的插件 ID 列表(暂未强制校验,预留设计) */ public List getDependencies() { return dependencies; } @@ -56,6 +59,7 @@ public class PluginDescriptor { private String description = ""; private List dependencies = Collections.emptyList(); private int order = 0; + private String icon = "📦"; public Builder id(String id) { this.id = id; return this; } public Builder name(String name) { this.name = name; return this; } @@ -63,6 +67,7 @@ public class PluginDescriptor { public Builder description(String desc) { this.description = desc; return this; } public Builder dependencies(List d) { this.dependencies = d; return this; } public Builder order(int order) { this.order = order; return this; } + public Builder icon(String icon) { this.icon = icon; return this; } public PluginDescriptor build() { if (id == null || id.isEmpty()) throw new IllegalArgumentException("plugin id is required"); diff --git a/src/main/java/com/example/workutils/system/plugin/VisualPluginFactory.java b/src/main/java/com/example/workutils/system/plugin/VisualPluginFactory.java new file mode 100644 index 0000000..7ef358f --- /dev/null +++ b/src/main/java/com/example/workutils/system/plugin/VisualPluginFactory.java @@ -0,0 +1,32 @@ +package com.example.workutils.system.plugin; + +import javafx.scene.Parent; + +/** + * 可视化窗口工厂 —— 插件通过实现此接口声明可视化能力。 + *

+ * 支持两种模式: + *

    + *
  • 内嵌模式(默认):由 {@link #getEmbeddedPane()} 返回内容面板, + * 主窗口将其嵌入右侧内容区。
  • + *
  • 独立窗口模式:用户点击"弹出"按钮或直接调用 {@link #open(Runnable)}, + * root 节点从内嵌面板拆离,放入独立 Stage 显示。
  • + *
+ * 在 JFX 线程中调用,窗口关闭后必须回调 onClosed 解除调用方线程阻塞。 + */ +public interface VisualPluginFactory { + + /** + * 在 JFX 线程中创建并显示独立窗口。 + * @param onClosed 窗口关闭回调(用于解除调用方线程阻塞) + */ + void open(Runnable onClosed); + + /** + * 返回可嵌入主窗口的内容面板。 + * 返回 null 表示该插件不支持内嵌模式,将直接打开独立窗口。 + */ + default Parent getEmbeddedPane() { + return null; + } +} diff --git a/src/main/java/com/example/workutils/system/support/HistoryPersistence.java b/src/main/java/com/example/workutils/system/support/HistoryPersistence.java new file mode 100644 index 0000000..95fafc2 --- /dev/null +++ b/src/main/java/com/example/workutils/system/support/HistoryPersistence.java @@ -0,0 +1,73 @@ +package com.example.workutils.system.support; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +/** + * 历史记录持久化工具 + *

+ * 将导出历史以文本行形式存储在 data/ 目录下,每次启动时自动加载。 + * 格式: queryCondition | fileName | count | timestamp + */ +public class HistoryPersistence { + + private final Path filePath; + + /** + * @param name 文件名(不含路径),如 "scanner_history.txt" + */ + public HistoryPersistence(String name) { + Path dataDir = Paths.get(System.getProperty("user.dir"), "data"); + try { + Files.createDirectories(dataDir); + } catch (IOException ignored) { + } + this.filePath = dataDir.resolve(name); + } + + /** + * 加载所有历史行(保留顺序,文件中最新的在前) + */ + public List load() { + List lines = new ArrayList<>(); + if (!Files.exists(filePath)) return lines; + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(new FileInputStream(filePath.toFile()), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + line = line.trim(); + if (!line.isEmpty()) { + lines.add(line); + } + } + } catch (IOException e) { + System.err.println("[HistoryPersistence] 读取历史文件失败: " + e.getMessage()); + } + return lines; + } + + /** + * 保存所有历史行到文件 + */ + public void save(List lines) { + try (BufferedWriter writer = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(filePath.toFile()), StandardCharsets.UTF_8))) { + for (String line : lines) { + writer.write(line); + writer.newLine(); + } + writer.flush(); + } catch (IOException e) { + System.err.println("[HistoryPersistence] 保存历史文件失败: " + e.getMessage()); + } + } + + public String getFilePath() { + return filePath.toString(); + } +} diff --git a/src/main/java/com/example/workutils/system/ui/MainWindow.java b/src/main/java/com/example/workutils/system/ui/MainWindow.java new file mode 100644 index 0000000..e2f2d2c --- /dev/null +++ b/src/main/java/com/example/workutils/system/ui/MainWindow.java @@ -0,0 +1,474 @@ +package com.example.workutils.system.ui; + +import com.example.workutils.system.config.WorkUtilsProperties; +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Orientation; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.TextAlignment; +import javafx.stage.Stage; + +import java.util.*; + +/** + * 工作工具集主窗口 —— 专业版设计 + *

+ * 左侧导航栏(slate-800) + 右侧内容区(slate-50)。 + * 插件默认内嵌运行,顶部统一工具栏提供「弹出」「关闭」。 + */ +public class MainWindow { + + private final Stage stage; + private final List navItems; + private final WorkUtilsProperties properties; + + private VBox navBox; + private StackPane contentArea; + private NavItem activeNavItem; + private String activePluginId; + private EmbeddedEntry currentEmbedded; + + /** 顶部工具栏控件 */ + private HBox pluginToolbar; + private Label toolbarIcon, toolbarTitle; + + /** 导航栏控件引用(响应式刷新用) */ + private Label appLabel, versionLabel; + private Label welcomeIcon, welcomeHeading, welcomeDesc; + + private final List