From 2c23679ebb0201b56c20398e5d36cf4e3faaf737 Mon Sep 17 00:00:00 2001 From: yiciheng Date: Sun, 16 Aug 2026 11:52:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BE=AE=E4=BF=A1=20AI=20=E5=8A=A9?= =?UTF-8?q?=E6=89=8B=E6=8F=92=E4=BB=B6=EF=BC=88wechat-ai=EF=BC=89=E5=9F=BA?= =?UTF-8?q?=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除旧 aitest 与 mobile-ui-auto 插件及其资源、测试 - 新增纯 Java 微信 AI 助手插件(ADB 驱动 + AI 回复) - 同步更新主界面图标映射、主题样式与插件配置 --- .../plans/wechat-ai-helper-plugin_2e557607.md | 218 ++++ .../系统设置重构-直接打开配置文件_16998c56.md | 228 ++++ .codebuddy/rules/workutils-ui.md | 36 + .codebuddy/rules/workutils.yaml | 133 ++ .gitignore | 50 + .mvn/wrapper/maven-wrapper.jar | Bin 0 -> 62547 bytes .mvn/wrapper/maven-wrapper.properties | 2 + README.md | 0 mvnw | 308 +++++ mvnw.cmd | 205 +++ pom.xml | 288 ++++ .../homeutils/HomeUtilsApplication.java | 263 ++++ .../biz/wechatai/WechatAiPlugin.java | 78 ++ .../biz/wechatai/WechatAiService.java | 436 +++++++ .../biz/wechatai/WechatAiWindow.java | 797 ++++++++++++ .../homeutils/biz/wechatai/adb/AdbClient.java | 439 +++++++ .../homeutils/biz/wechatai/adb/AdbDevice.java | 27 + .../homeutils/biz/wechatai/ai/AiConfig.java | 31 + .../biz/wechatai/ai/AiResponder.java | 259 ++++ .../biz/wechatai/ai/ReplyTemplate.java | 11 + .../wechatai/config/WechatAiProperties.java | 194 +++ .../biz/wechatai/wechat/MessageMonitor.java | 231 ++++ .../biz/wechatai/wechat/WechatMessage.java | 29 + .../biz/wechatai/wechat/WechatSession.java | 20 + .../wechatai/wechat/WechatSessionReader.java | 279 ++++ .../system/config/ConfigInitializer.java | 35 + .../system/config/DatasourceConfig.java | 25 + .../config/DatasourceConfigManager.java | 84 ++ .../system/config/ExternalConfigManager.java | 175 +++ .../system/config/HomeUtilsProperties.java | 420 ++++++ .../system/config/SettingsService.java | 55 + .../system/config/SettingsWindow.java | 1159 +++++++++++++++++ .../homeutils/system/plugin/Plugin.java | 70 + .../system/plugin/PluginContext.java | 61 + .../system/plugin/PluginDescriptor.java | 78 ++ .../system/plugin/PluginManagementWindow.java | 377 ++++++ .../system/plugin/PluginManager.java | 332 +++++ .../homeutils/system/plugin/PluginState.java | 22 + .../system/plugin/VisualPluginFactory.java | 32 + .../system/support/AppExecutors.java | 28 + .../system/support/ApplicationPaths.java | 68 + .../system/support/FileOperationHelper.java | 232 ++++ .../system/support/HistoryPersistence.java | 88 ++ .../system/support/JfxBootstrap.java | 49 + .../system/support/OutputHelper.java | 144 ++ .../system/support/SystemOpenHelper.java | 191 +++ .../homeutils/system/support/WinConsole.java | 100 ++ .../homeutils/system/ui/DesignTokens.java | 40 + .../homeutils/system/ui/EmptyStatePane.java | 56 + .../homeutils/system/ui/MainWindow.java | 331 +++++ .../homeutils/system/ui/StyledButton.java | 53 + .../example/homeutils/system/ui/Theme.java | 224 ++++ .../ui/async/AsyncOperationController.java | 236 ++++ .../system/ui/async/LoadingButtonBinder.java | 168 +++ .../system/ui/async/OperationContext.java | 28 + .../system/ui/async/OperationSnapshot.java | 8 + .../system/ui/async/OperationState.java | 10 + .../ui/async/OperationStateMachine.java | 67 + .../system/ui/async/OperationStatusPane.java | 130 ++ .../system/ui/async/OperationTask.java | 6 + .../system/ui/history/ExportHistoryPane.java | 239 ++++ .../system/ui/history/FileActionGateway.java | 15 + .../ui/history/HistoryFileCommands.java | 39 + .../system/ui/history/HistoryItem.java | 36 + .../system/ui/history/ResultFileActions.java | 90 ++ .../ui/history/SystemFileActionGateway.java | 33 + .../homeutils/system/ui/icon/AppIcon.java | 33 + .../homeutils/system/ui/icon/IconFactory.java | 25 + .../ui/notification/FxNotificationCenter.java | 106 ++ .../ui/notification/NotificationCenter.java | 24 + .../ui/notification/NotificationLevel.java | 7 + .../ui/notification/NotificationMessage.java | 21 + .../system/ui/shell/NavigationRail.java | 281 ++++ .../homeutils/system/ui/shell/PageHeader.java | 31 + src/main/resources/app_icon.ico | Bin 0 -> 41086 bytes src/main/resources/app_icon.png | Bin 0 -> 103606 bytes src/main/resources/application.yml | 25 + src/main/resources/css/light-theme.css | 634 +++++++++ src/main/resources/logback.xml | 20 + .../resources/mybatis-generator.properties | 11 + .../resources/templates/api_base_classes.json | 45 + .../templates/apidoc/api-doc-snapshot.js | 137 ++ .../resources/templates/apidoc/api-doc.css | 239 ++++ .../resources/templates/apidoc/api-doc.html | 47 + .../resources/templates/apidoc/api-doc.js | 1088 ++++++++++++++++ .../templates/apidoc/html2canvas.min.js | 20 + .../resources/templates/damaged_repos.cache | 1 + src/main/resources/templates/datasources.json | 23 + src/main/resources/templates/homeutils.config | 28 + .../templates/mybatis-generator.properties | 11 + src/main/resources/templates/plugins.config | 5 + .../resources/templates/project_paths.txt | 72 + src/main/resources/投保-评审修改.sql | 355 +++++ .../config/HomeUtilsPropertiesPathTest.java | 54 + .../DefaultPluginConfigurationTest.java | 48 + .../system/support/ApplicationPathsTest.java | 64 + .../system/support/BuildPortabilityTest.java | 110 ++ .../support/FileOperationHelperPathTest.java | 31 + .../support/HistoryPersistencePathTest.java | 27 + .../homeutils/system/ui/DesignTokensTest.java | 31 + .../system/ui/EmptyStateContentTest.java | 25 + .../homeutils/system/ui/FxTestSupport.java | 44 + .../system/ui/FxTestSupportTest.java | 17 + .../system/ui/LightThemeCssRuntimeTest.java | 353 +++++ .../system/ui/MainWindowArchitectureTest.java | 114 ++ ...iveUiSceneVisualAcceptanceFixtureTest.java | 118 ++ .../ui/NativeUiVisualAcceptanceFixture.java | 510 ++++++++ ...UiVisualAcceptanceFixtureContractTest.java | 132 ++ .../NativeUiVisualAcceptanceFixtureTest.java | 93 ++ .../ui/SharedPaneAccessibilityTest.java | 193 +++ .../async/AsyncOperationControllerTest.java | 556 ++++++++ .../ui/async/LoadingButtonTextTest.java | 232 ++++ .../ui/async/OperationContextTestSupport.java | 13 + .../ui/async/OperationStateMachineTest.java | 88 ++ .../history/ExportHistoryPaneLayoutTest.java | 81 ++ .../history/ExportHistoryPaneLoadingTest.java | 112 ++ .../ui/history/HistoryFileCommandsTest.java | 36 + .../system/ui/history/HistoryItemTest.java | 33 + .../ui/history/ResultFileActionsTest.java | 135 ++ .../system/ui/icon/IconFactoryTest.java | 31 + .../FxNotificationCenterTest.java | 20 + .../notification/NotificationMessageTest.java | 51 + .../system/ui/shell/NavigationRailTest.java | 556 ++++++++ 123 files changed, 17393 insertions(+) create mode 100644 .codebuddy/plans/wechat-ai-helper-plugin_2e557607.md create mode 100644 .codebuddy/plans/系统设置重构-直接打开配置文件_16998c56.md create mode 100644 .codebuddy/rules/workutils-ui.md create mode 100644 .codebuddy/rules/workutils.yaml create mode 100644 .gitignore create mode 100644 .mvn/wrapper/maven-wrapper.jar create mode 100644 .mvn/wrapper/maven-wrapper.properties create mode 100644 README.md create mode 100644 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/com/example/homeutils/HomeUtilsApplication.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/WechatAiPlugin.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/WechatAiService.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/WechatAiWindow.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/adb/AdbClient.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/adb/AdbDevice.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/ai/AiConfig.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/ai/AiResponder.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/ai/ReplyTemplate.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/config/WechatAiProperties.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/wechat/MessageMonitor.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatMessage.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSession.java create mode 100644 src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSessionReader.java create mode 100644 src/main/java/com/example/homeutils/system/config/ConfigInitializer.java create mode 100644 src/main/java/com/example/homeutils/system/config/DatasourceConfig.java create mode 100644 src/main/java/com/example/homeutils/system/config/DatasourceConfigManager.java create mode 100644 src/main/java/com/example/homeutils/system/config/ExternalConfigManager.java create mode 100644 src/main/java/com/example/homeutils/system/config/HomeUtilsProperties.java create mode 100644 src/main/java/com/example/homeutils/system/config/SettingsService.java create mode 100644 src/main/java/com/example/homeutils/system/config/SettingsWindow.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/Plugin.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/PluginContext.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/PluginDescriptor.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/PluginManagementWindow.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/PluginManager.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/PluginState.java create mode 100644 src/main/java/com/example/homeutils/system/plugin/VisualPluginFactory.java create mode 100644 src/main/java/com/example/homeutils/system/support/AppExecutors.java create mode 100644 src/main/java/com/example/homeutils/system/support/ApplicationPaths.java create mode 100644 src/main/java/com/example/homeutils/system/support/FileOperationHelper.java create mode 100644 src/main/java/com/example/homeutils/system/support/HistoryPersistence.java create mode 100644 src/main/java/com/example/homeutils/system/support/JfxBootstrap.java create mode 100644 src/main/java/com/example/homeutils/system/support/OutputHelper.java create mode 100644 src/main/java/com/example/homeutils/system/support/SystemOpenHelper.java create mode 100644 src/main/java/com/example/homeutils/system/support/WinConsole.java create mode 100644 src/main/java/com/example/homeutils/system/ui/DesignTokens.java create mode 100644 src/main/java/com/example/homeutils/system/ui/EmptyStatePane.java create mode 100644 src/main/java/com/example/homeutils/system/ui/MainWindow.java create mode 100644 src/main/java/com/example/homeutils/system/ui/StyledButton.java create mode 100644 src/main/java/com/example/homeutils/system/ui/Theme.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/AsyncOperationController.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/LoadingButtonBinder.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationContext.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationSnapshot.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationState.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationStateMachine.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationStatusPane.java create mode 100644 src/main/java/com/example/homeutils/system/ui/async/OperationTask.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/ExportHistoryPane.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/FileActionGateway.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/HistoryFileCommands.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/HistoryItem.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/ResultFileActions.java create mode 100644 src/main/java/com/example/homeutils/system/ui/history/SystemFileActionGateway.java create mode 100644 src/main/java/com/example/homeutils/system/ui/icon/AppIcon.java create mode 100644 src/main/java/com/example/homeutils/system/ui/icon/IconFactory.java create mode 100644 src/main/java/com/example/homeutils/system/ui/notification/FxNotificationCenter.java create mode 100644 src/main/java/com/example/homeutils/system/ui/notification/NotificationCenter.java create mode 100644 src/main/java/com/example/homeutils/system/ui/notification/NotificationLevel.java create mode 100644 src/main/java/com/example/homeutils/system/ui/notification/NotificationMessage.java create mode 100644 src/main/java/com/example/homeutils/system/ui/shell/NavigationRail.java create mode 100644 src/main/java/com/example/homeutils/system/ui/shell/PageHeader.java create mode 100644 src/main/resources/app_icon.ico create mode 100644 src/main/resources/app_icon.png create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/css/light-theme.css create mode 100644 src/main/resources/logback.xml create mode 100644 src/main/resources/mybatis-generator.properties create mode 100644 src/main/resources/templates/api_base_classes.json create mode 100644 src/main/resources/templates/apidoc/api-doc-snapshot.js create mode 100644 src/main/resources/templates/apidoc/api-doc.css create mode 100644 src/main/resources/templates/apidoc/api-doc.html create mode 100644 src/main/resources/templates/apidoc/api-doc.js create mode 100644 src/main/resources/templates/apidoc/html2canvas.min.js create mode 100644 src/main/resources/templates/damaged_repos.cache create mode 100644 src/main/resources/templates/datasources.json create mode 100644 src/main/resources/templates/homeutils.config create mode 100644 src/main/resources/templates/mybatis-generator.properties create mode 100644 src/main/resources/templates/plugins.config create mode 100644 src/main/resources/templates/project_paths.txt create mode 100644 src/main/resources/投保-评审修改.sql create mode 100644 src/test/java/com/example/homeutils/system/config/HomeUtilsPropertiesPathTest.java create mode 100644 src/test/java/com/example/homeutils/system/plugin/DefaultPluginConfigurationTest.java create mode 100644 src/test/java/com/example/homeutils/system/support/ApplicationPathsTest.java create mode 100644 src/test/java/com/example/homeutils/system/support/BuildPortabilityTest.java create mode 100644 src/test/java/com/example/homeutils/system/support/FileOperationHelperPathTest.java create mode 100644 src/test/java/com/example/homeutils/system/support/HistoryPersistencePathTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/DesignTokensTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/EmptyStateContentTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/FxTestSupport.java create mode 100644 src/test/java/com/example/homeutils/system/ui/FxTestSupportTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/LightThemeCssRuntimeTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/MainWindowArchitectureTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixture.java create mode 100644 src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/SharedPaneAccessibilityTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/async/AsyncOperationControllerTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/async/LoadingButtonTextTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/async/OperationContextTestSupport.java create mode 100644 src/test/java/com/example/homeutils/system/ui/async/OperationStateMachineTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/history/ExportHistoryPaneLayoutTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/history/ExportHistoryPaneLoadingTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/history/HistoryFileCommandsTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/history/HistoryItemTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/history/ResultFileActionsTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/icon/IconFactoryTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/notification/FxNotificationCenterTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/notification/NotificationMessageTest.java create mode 100644 src/test/java/com/example/homeutils/system/ui/shell/NavigationRailTest.java diff --git a/.codebuddy/plans/wechat-ai-helper-plugin_2e557607.md b/.codebuddy/plans/wechat-ai-helper-plugin_2e557607.md new file mode 100644 index 0000000..4f51904 --- /dev/null +++ b/.codebuddy/plans/wechat-ai-helper-plugin_2e557607.md @@ -0,0 +1,218 @@ +--- +name: wechat-ai-helper-plugin +overview: 删除现有 mobile-ui-auto 手机 UI 自动化插件(含 Python/Airtest 依赖),全新编写纯 Java 的"微信 AI 助手"插件:通过 ADB 读取安卓微信聊天记录、监测指定联系人的新消息,并按用户配置的话术由大模型 AI 自动/半自动回复。 +design: + styleKeywords: + - 深色科技 + - 卡片化面板 + - 状态徽标 + - 分栏布局 + - 克制动效 + - 高对比可读性 + fontSystem: + fontFamily: Microsoft YaHei UI + heading: + size: 20px + weight: 600 + subheading: + size: 15px + weight: 600 + body: + size: 13px + weight: 400 + colorSystem: + primary: + - "#4F8CFF" + - "#00C9A7" + background: + - "#181D26" + - "#1E2430" + - "#262D3B" + text: + - "#E8ECF4" + - "#9AA5B8" + - "#FFFFFF" + functional: + - "#27AE60" + - "#EB5757" + - "#F2994A" + - "#56CCF2" +todos: + - id: remove-old-plugin + content: 用 [mcp:Windows CLI MCP Server] 删除旧 mobile-ui-auto 代码/资源/data 目录,并用 [subagent:code-explorer] 复核引用点后同步更新 plugins.config、MainWindow 图标映射与插件测试 + status: completed + - id: adb-wechat-reader + content: 实现 AdbClient 与 WechatSessionReader,完成设备连接、微信启动、dump 解析与聊天记录读取 + status: completed + dependencies: + - remove-old-plugin + - id: monitor-ai-engine + content: 实现 MessageMonitor 快照比对监测与 AiResponder 模板命中+AI 生成,含 config.json 持久化 + status: completed + dependencies: + - adb-wechat-reader + - id: wechat-ai-ui + content: 实现 WechatAiWindow 四页签界面与 WechatAiPlugin 注册,整合联系人/话术/AI 配置与监控日志 + status: completed + dependencies: + - monitor-ai-engine + - id: compile-verify + content: 用 [mcp:Windows CLI MCP Server] 执行 mvnw compile 并启动应用,验证插件加载、配置生效与旧插件消失 + status: completed + dependencies: + - wechat-ai-ui +--- + +## 产品概述 + +将现有"手机UI自动化"插件(基于 Python/Airtest 的测试工具)整体删除重写,全新打造"微信AI助手"插件(方案C)。插件通过 ADB 连接安卓手机微信,实现聊天记录读取、指定联系人消息监测、AI 按用户话术自动回复三大能力,纯 Java 实现、无 Python 依赖。 + +## 核心功能 + +- **设备连接与微信启动**:USB/无线 ADB 设备枚举与连接,一键启动手机微信,前台状态检测。 +- **聊天记录读取**:`uiautomator dump` 提取消息文本节点(精确免费),支持滚动加载历史记录;截图保存作为视觉兜底,可识别图片消息。 +- **指定联系人监测**:维护"指定人"名单,定时轮询会话最新消息与快照比对,检测新消息并推送事件。 +- **AI 话术回复**:人设描述驱动 AI 自由发挥 + 固定模板兜底(关键词命中优先),调用 OpenAI 兼容接口(base-url/api-key/model 可配)。 +- **回复模式**:默认半自动(PC 端预览 AI 回复,点击"发送"才在手机输入发送),提供全自动开关。 +- **监控与日志**:消息事件、AI 生成内容、发送结果完整记录,支持手动重发。 + +## 交互界面 + +JavaFX 桌面窗口,深色科技风,顶部工具栏 + 中央分栏布局:左侧聊天记录预览,右侧配置面板(联系人/话术与AI/监控日志三个页签),设备、微信、监控、AI 四色状态徽标实时反馈。 + +## 技术栈 + +- Java 17 + Spring Boot 2.7.18 + JavaFX 17(沿用现有桌面架构) +- ADB:直接调用 `adb` 命令行(`devices` / `shell am start` / `shell uiautomator dump` / `shell screencap` / `shell input tap|text|swipe` / `shell dumpsys window`) +- AI 调用:JDK17 自带 `java.net.http.HttpClient` 调 OpenAI 兼容 `chat/completions`,**不新增 Maven 依赖**(pom 不动) +- 配置持久化:`data/wechat-ai/config.json`(fastjson 读写,沿用项目现有 JSON 序列化方式) +- 日志:SLF4J + logback(项目现有),UI 日志区双写 + +## 实现方式 + +**策略**:删除旧插件后,以"纯 Java ADB 直连 + 文本 dump 优先 + 视觉兜底"的方式重建。微信聊天界面是原生控件,`uiautomator dump` 可直接拿到消息文本与发送者,比截图识图更精确、零成本;截图仅在新消息检测/图片消息/解析失败兜底时触发,配合多模态模型识别。 + +**关键决策**: + +- dump 优先而非全量识图:uiautomator XML 直接含 text/content-desc 属性,解析效率高、无 OCR 误差;视觉模型仅在兜底路径启用。 +- 轮询快照比对:Monitor 记录每个指定会话的最新消息 hash,仅在新 hash 出现时才触发 AI,避免重复回复。 +- 半自动默认 + 全自动开关:AI 生成结果先回传 UI 预览,防止误发;开关一键切换。 +- 话术两级:固定模板(关键词→固定回复,命中直接采用)优先于 AI 生成,保证确定性场景可控。 +- 所有 AI/ADB 参数在 UI 可配并持久化,模型/密钥不硬编码。 + +**性能与可靠性**: + +- 轮询默认间隔 3~5 秒(可配),单次 dump 约 1~2 秒,解析仅取文本节点 O(n),无性能瓶颈;截图仅在必要时触发。 +- AI 请求设置 30s 超时 + 单次重试,失败不阻塞监控线程,事件落入日志。 +- 边界处理:设备断开、未授权、微信未安装/未登录、dump 失败(回退截图+视觉)、消息为空/超长(截断)。 + +**日志**:AI 请求记录到 UI 日志区(截断至 200 字,不打印 api-key);错误可操作性强、不 dump 大对象。 + +**爆炸半径控制**:删除前全仓复核 `mobile-ui-auto`/`mobileuiauto` 引用(已定位 MainWindow 图标映射、plugins.config 模板、插件配置测试);新插件 ID 独立,不影响其他插件;data 目录占用删除失败时提示手动清理、不阻塞编译。 + +## 架构设计 + +```mermaid +graph LR + UI[WechatAiWindow JavaFX] --> SVC[WechatAiService] + SVC --> ADB[AdbClient] + SVC --> MON[MessageMonitor] + SVC --> AI[AiResponder] + ADB --> PHONE[安卓手机微信] + MON --> READER[WechatSessionReader] + READER --> ADB + AI --> HTTP[OpenAI兼容API] + SVC --> CFG[config.json 持久化] + UI --> EVT[事件回调: 设备状态/新消息/AI结果/日志] + EVT --> UI +``` + +**模块划分**: + +- `adb` 层:AdbClient(命令封装)、AdbDevice(设备模型)——独立于微信逻辑,可复用。 +- `wechat` 层:WechatSessionReader(dump 解析/滚动/截图)、WechatMessage/WechatSession(模型)、MessageMonitor(轮询监测,事件回调)。 +- `ai` 层:AiResponder(消息组装/模板命中/HTTP 调用/超时重试)、AiConfig(模型参数)。 +- `ui` 层:WechatAiWindow(四页签)、WechatAiPlugin(插件注册与生命周期)。 + +**数据流**:Monitor 轮询 → Reader dump 会话页 → 解析文本节点 → 快照比对发现新消息 → 回调 UI(消息+截图)→ AiResponder 组装(system 人设 + 历史 + 模板)→ 生成回复 → 半自动预览/全自动直发 → AdbClient input 输入发送 → 日志记录。 + +## 目录结构 + +### 删除(方案C) + +``` +src/main/java/com/example/homeutils/biz/mobileuiauto/ # [DELETE] 整个包(Plugin/Window/Service/7个model,共10个文件) +src/main/resources/mobile-ui-auto/ # [DELETE] 整个目录(python/runner.py、requirements.txt、install_tools.py、templates) +data/mobile-ui-auto/ # [DELETE] 运行数据(含 .venv,占用时提示手动清理) +``` + +### 修改 + +``` +src/main/resources/templates/plugins.config # [MODIFY] plugins.order=mobile-ui-auto → wechat-ai +src/main/java/com/example/homeutils/system/ui/MainWindow.java # [MODIFY] 第325行 AppIcon switch 的 "mobile-ui-auto" case 替换为新ID(先复核该switch) +src/test/java/com/example/homeutils/system/plugin/DefaultPluginConfigurationTest.java # [MODIFY] 测试配置模板中 mobile-ui-auto 引用更新为新ID +``` + +### 新建 + +``` +src/main/java/com/example/homeutils/biz/wechatai/ +├── WechatAiPlugin.java # [NEW] 插件注册:id=wechat-ai,name=微信AI助手,commandName=wechat-ai,实现 Plugin 生命周期与 VisualPluginFactory +├── WechatAiWindow.java # [NEW] JavaFX 主窗口:顶部工具栏(设备/启动微信/监控主开关+模式切换)、中央分栏(左侧聊天记录预览+截图,右侧 TabPane: 联系人/话术与AI/监控日志);沿用 Theme/StyledButton +├── WechatAiService.java # [NEW] 编排服务:组装 AdbClient/Reader/Monitor/AiResponder,提供 start/stop/发送/配置读写,事件分发到 UI +├── adb/ +│ ├── AdbClient.java # [NEW] adb 命令封装:listDevices/connect/tap/input/screenshot/dumpUiXml/foregroundPackage/startApp;解析 dump XML 文本节点 +│ └── AdbDevice.java # [NEW] 设备模型(serial/state/model),含无线连接地址解析 +├── wechat/ +│ ├── WechatSessionReader.java # [NEW] 打开微信(com.tencent.mm)→dump会话列表/聊天页→解析消息(发送者/内容/时间)→滚动加载→截图 +│ ├── WechatMessage.java # [NEW] 消息模型(sender/content/time/type) +│ ├── WechatSession.java # [NEW] 会话模型(title/messages/lastHash) +│ └── MessageMonitor.java # [NEW] 定时轮询线程:快照比对检测指定联系人的新消息,回调事件;支持启动/停止/间隔配置 +├── ai/ +│ ├── AiResponder.java # [NEW] HttpClient 调 chat/completions;system=人设+话术;固定模板命中优先;超时30s+重试1次;可选 base64 视觉消息 +│ └── AiConfig.java # [NEW] 模型配置模型(baseUrl/apiKey/model/温度/超时) +└── config/ + └── WechatAiProperties.java # [NEW] data/wechat-ai/config.json 读写:联系人名单/话术模板/AI配置/监控间隔/模式开关 +``` + +## 关键代码结构 + +```java +// AdbClient 核心接口(adb 命令封装,全部通过 ProcessBuilder 执行) +public class AdbClient { + List listDevices() throws IOException; + String foregroundPackage(String serial) throws IOException; + void startApp(String serial, String packageName) throws IOException; + Path screenshot(String serial, Path outPng) throws IOException; + List dumpUiXml(String serial) throws IOException; // uiautomator dump + XML 解析 + void tap(String serial, int x, int y) throws IOException; + void inputText(String serial, String text) throws IOException; // 中文经 ADBKeyboard 或剪贴板方式处理 + void swipe(String serial, int x1, int y1, int x2, int y2, int ms) throws IOException; +} + +// MessageMonitor 事件回调(解耦监测与 UI/AI) +public interface MessageMonitorListener { + void onNewMessage(WechatSession session, WechatMessage msg, Path screenshot); + void onMonitorError(String message); + void onStatusChanged(boolean running); +} +``` + +## 设计风格 + +深色现代科技风桌面应用,与 homeutils 现有 Theme(BG_BODY 深色系、StyledButton、AtlantaFX)体系保持一致。顶部工具栏承载核心操作(设备连接、启动微信、监控主开关、自动/手动模式切换),配四色状态徽标(设备/微信/监控/AI)。中央采用分栏布局:左侧聊天记录列表 + 消息详情/手机截图预览,右侧设置面板以 TabPane 组织(指定联系人、话术与 AI、监控日志)。卡片化面板、圆角、克制的悬停反馈与状态色点缀,信息层级清晰,长时间监控不刺眼。 + +## Agent Extensions + +### MCP + +- **Windows CLI MCP Server** +- Purpose: 执行删除操作(`cmd /c rmdir /s /q` 删除旧插件代码/资源/data 目录,规避 Windows 文件占用拦截)、编译验证(`mvnw.cmd compile -q`)、启动应用并检查 data/plugins.config 与侧边栏插件加载结果 +- Expected outcome: 旧插件相关文件与 Python 环境被彻底清理,新插件编译通过、应用启动后"微信AI助手"出现在侧边栏且原"手机UI自动化"消失 + +### SubAgent + +- **code-explorer** +- Purpose: 在删除前全仓复核 `mobile-ui-auto`/`mobileuiauto`/`MobileUiAutomation` 的所有引用点(MainWindow 图标 switch、配置、测试、文档),确认无遗漏后才执行删除 +- Expected outcome: 输出完整引用清单,确保删除后编译无残留错误、无死引用 \ No newline at end of file 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/.codebuddy/rules/workutils-ui.md b/.codebuddy/rules/workutils-ui.md new file mode 100644 index 0000000..d1cb6f6 --- /dev/null +++ b/.codebuddy/rules/workutils-ui.md @@ -0,0 +1,36 @@ +# WorkUtils UI 开发规则 + +## 1. CSS 优先级铁律(AtlantaFX 环境) + +### 文本颜色 +- **禁止**纯 CSS 设置导航栏选中文字色,AtlantaFX `.label` 会覆盖(含 `!important`)。 +- **正确做法**:Java 代码中 `label.setTextFill(Color.WHITE)` 直接设置。 + +### 背景透明 +- ListView 内部容器透明需覆盖完整链路:`.list-view`, `.viewport`, `.virtual-flow`, `.virtual-flow > *`, `.clipped-container`, `.sheet`, `.corner`。 +- 同时用 Java 代码 `setStyle("-fx-background-color: transparent;")` 做 inline 兜底。 + +### 选中态 +- **必须同时覆盖** `.list-cell:selected` 和 `.list-cell:filled:selected`。 +- `.list-cell:selected` 设置透明背景兜底 → 防止虚拟化复用时空 cell 闪现蓝色。 +- `.list-cell:filled:selected` 设置激活背景 → CSS 优先级更高,非空选中 cell 生效。 + +## 2. ListView 虚拟化陷阱 + +- ListView 使用 VirtualFlow,cell 会被复用。每次 `updateItem(empty=true)` 时: + - 必须清理自定义 style class(先 removeAll 再按需 add)。 + - 禁止 empty 分支中直接用 `setAll()`(会误删 JavaFX 内置 `list-cell` class)。 +- `selectedProperty()` listener 中调用 `updateStyleClass()` 时,要兼容 empty 状态。 + +## 3. 布局规则 + +- ListView 导航栏使用 `VBox.setVgrow(navList, Priority.ALWAYS)` 撑满剩余空间。 +- **不要**在 `refreshNavItems()` 中重新设置 prefHeight/minHeight/maxHeight,会与 grow 冲突。 +- 滚动条自然出现在 ListView 右侧底部,无需额外处理。 + +## 4. 修改验证流程 + +修改 UI 样式后: +1. 先检查 AtlantaFX/modena.css 是否有同名规则覆盖。 +2. 优先使用 Java 端代码设置(`setStyle`/`setTextFill`),再补充 CSS。 +3. 对虚拟化组件(ListView/TableView),验证 resize 窗口场景。 diff --git a/.codebuddy/rules/workutils.yaml b/.codebuddy/rules/workutils.yaml new file mode 100644 index 0000000..45fb3c4 --- /dev/null +++ b/.codebuddy/rules/workutils.yaml @@ -0,0 +1,133 @@ +# WorkUtils 项目规则 +# https://www.codebuddy.ai/docs/zh/IDE/User-guide/Workspace +version: 1 + +# ===== 项目信息 ===== +name: workutils +description: WorkUtils API 文档生成工具 + +# ===== 规则定义 ===== +rules: + + # 1. 导出目录 —— 禁止全文搜索 + - id: no-export-search + description: | + 禁止对 export/ 目录进行搜索/读取操作。 + 该目录包含导出的 HTML 文件,单文件可达数 MB,ripgrep 默认跳过超长行导致结果不准确。 + condition: "path starts with 'export/'" + action: "reject" + fallback: "如需调试导出结果,使用源码模板文件 (templates/apidoc/*.js) + 单元测试验证" + + # 2. 模板规范 —— 禁止 code-in-string 反模式 + - id: no-code-in-string + description: | + 禁止在 JS 中用函数返回压缩字符串来重复已有代码。 + 典型反模式:SNAPSHOT_JS() { return 'function sendDubboRequest(){...}' } + 这种方式导致: + - 三层转义地狱 (JS字符串→HTML→JS参数) + - 无法调试、无法 lint、无法语法高亮 + - 代码漂移:修改 api-doc.js 后快照版不会同步 + 正确方式:window.__API_DOC_JS__ = {{JS_JSON}}; // 复用原文 + example: | + # 错误示例 + function SNAPSHOT_JS() { + return 'function sendDubboRequest(){var e=buildDubboBody();...}'; + } + # 正确示例 + var mainJs = window.__API_DOC_JS__; // 由 Java 注入的 JSON + var override = 'function getActiveDubboCard(){...}'; + var fullJs = override + '\n' + mainJs; + enforcement: "warn" + severity: high + + # 3. 占位符命名规范 + - id: placeholder-naming + description: "模板占位符统一使用 {{NAME}} 格式,双大写加下划线" + pattern: "\\{\\{[A-Z][A-Z0-9_]*\\}\\}" + enforcement: "review" + + # 4. 大文件读取检查 + - id: large-file-check + description: | + 读取 >500KB 文件前先检查行数和内容分布。 + 超大文件(如压缩后的 JS/CSS、生成的 HTML)不适合直接读取全文。 + condition: "file.size > 500KB" + action: "warn" + suggestion: "使用 head/tail 检查文件结构,或使用单元测试验证行为" + + # 5. 转义函数集中管理 + - id: escape-centralized + description: | + HTML/JS/JSON 转义函数统一在 Java 侧或独立工具模块管理。 + 避免在 JS 源码中混用多种转义方式(\\、\\\、\\'、' 等)。 + files: ["ApiDocGenerator.java"] + enforcement: "review" + + # 6. Git 忽略大型产物 + - id: gitignore-export + description: "export/ 目录应加入 .gitignore,避免提交大型产物文件" + action: "suggest" + suggestion: "echo 'export/' >> .gitignore" + + # 7. 复杂重构分步提交 —— 避免会话中断丢失工作 + - id: refactor-step-by-step + description: | + 涉及 ≥4 个文件 或 ≥200 行变更 的复杂重构,必须分多个 commit 完成,避免单次会话中断丢失全部工作。 + 典型场景: + - 字段/类重命名(如 navButtons → navList)会触发多文件交叉引用更新 + - 组件替换(Button → ListView)涉及字段、方法、引用、样式、测试多文件 + - 框架/库升级涉及配置文件、依赖、调用点、文档 + 推荐步骤: + ① 第一步:加新常量/CSS/配置项(与旧实现共存) + ② 第二步:替换实现/切换调用点 + ③ 第三步:删除旧代码(清理 commit) + 每步完成后提交,便于回滚。 + condition: "涉及文件数 >= 4 OR 变更行数 >= 200" + action: "warn" + severity: high + + # 8. AtlantaFX UI 防中断规则 —— 修改 MainWindow/Theme/CSS 时必读 + - id: atlantafx-ui-pitfalls + description: | + 修改 MainWindow.java、Theme.java 或 light-theme.css 时,必须遵守以下规则,避免反复修改同一问题导致会话中断: + + 【CSS 优先级】 + - AtlantaFX 的 .label 规则优先级极高,!important 也会被覆盖 + - 导航栏选中文字色:必须用 Java 代码 label.setTextFill(Color.WHITE),不能靠 CSS + - ListView 选中态:必须同时覆盖 .list-cell:selected(为空 cell 兜底)和 .list-cell:filled:selected(为非空 cell 设置激活背景) + - 背景透明:需要完整覆盖 .list-view → .virtual-flow → .clipped-container → .sheet 全链路 + + 【ListView 虚拟化】 + - cell 会被 VirtualFlow 复用,updateItem(empty=true) 时必须清理自定义 style class + - 不要用 getStyleClass().setAll(),会误删 JavaFX 内置 list-cell class;用 removeAll + add + - 窗口 resize 时 cell 复用会暴露 empty cell 被 selected 的问题 + + 【布局】 + - 导航 ListView 用 VBox.setVgrow(ALWAYS) 撑满 + - refreshNavItems() 中不要重新设置 prefHeight/minHeight/maxHeight + + 【修改后自检清单】 + ① 小窗口(菜单项少)→ 无滚动条,无底部蓝块 + ② 选中菜单 → 文字白色,背景蓝色,左侧蓝条可见 + ③ 最大化窗口 → 无额外蓝色块闪现,滚动条在导航栏底部 + files: ["MainWindow.java", "Theme.java", "light-theme.css"] + action: "warn" + severity: high + +# ===== 触发提示 ===== +# 当规则被触发时,AI 会看到这些提示 +messages: + no-export-search: | + ⚠️ 检测到对 export/ 目录的操作。 + 该目录包含导出的 HTML 文件(单文件可达数 MB),不适合搜索/读取。 + 请改用源码模板文件 (src/main/resources/templates/apidoc/*.js) 进行调试。 + large-file-check: | + ⚠️ 目标文件 >500KB,可能是压缩产物或大型导出文件。 + 建议使用 head/tail 检查结构,或通过单元测试验证行为。 + refactor-step-by-step: | + ⚠️ 检测到复杂重构(≥4 文件 或 ≥200 行)。 + 建议分步提交: + ① 第一步:加新常量/CSS(与旧实现共存) + ② 第二步:替换实现/切换调用点 + ③ 第三步:删除旧代码 + 每步完成后 git commit,避免会话中断丢失工作。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880fdd9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# === 构建产物 === +HELP.md +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +/dist/ + +# === 运行时数据文件 === +/data/ +/logs/ +/export/ +build.log + +# === 空模板目录保护 === +!/src/main/resources/config/ + +# === IDE === +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ +/.workbuddy/ +/.codefree/ +/.workbuddy/ +/scripts/ +/.codebuddy/*.json +/.worktrees/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..cb28b0e37c7d206feb564310fdeec0927af4123a GIT binary patch literal 62547 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 A?*IS* literal 0 HcmV?d00001 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..5f0536e --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.5/apache-maven-3.9.5-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/mvnw b/mvnw new file mode 100644 index 0000000..66df285 --- /dev/null +++ b/mvnw @@ -0,0 +1,308 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.2.0 +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "$(uname)" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + JAVA_HOME="$(/usr/libexec/java_home)"; export JAVA_HOME + else + JAVA_HOME="/Library/Java/Home"; export JAVA_HOME + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=$(java-config --jre-home) + fi +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --unix "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --unix "$CLASSPATH") +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] && + JAVA_HOME="$(cd "$JAVA_HOME" || (echo "cannot cd into $JAVA_HOME."; exit 1); pwd)" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="$(which javac)" + if [ -n "$javaExecutable" ] && ! [ "$(expr "\"$javaExecutable\"" : '\([^ ]*\)')" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=$(which readlink) + if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then + if $darwin ; then + javaHome="$(dirname "\"$javaExecutable\"")" + javaExecutable="$(cd "\"$javaHome\"" && pwd -P)/javac" + else + javaExecutable="$(readlink -f "\"$javaExecutable\"")" + fi + javaHome="$(dirname "\"$javaExecutable\"")" + javaHome=$(expr "$javaHome" : '\(.*\)/bin') + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="$(\unset -f command 2>/dev/null; \command -v java)" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=$(cd "$wdir/.." || exit 1; pwd) + fi + # end of workaround + done + printf '%s' "$(cd "$basedir" || exit 1; pwd)" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + # Remove \r in case we run on Windows within Git Bash + # and check out the repository with auto CRLF management + # enabled. Otherwise, we may read lines that are delimited with + # \r\n and produce $'-Xarg\r' rather than -Xarg due to word + # splitting rules. + tr -s '\r\n' ' ' < "$1" + fi +} + +log() { + if [ "$MVNW_VERBOSE" = true ]; then + printf '%s\n' "$1" + fi +} + +BASE_DIR=$(find_maven_basedir "$(dirname "$0")") +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"}; export MAVEN_PROJECTBASEDIR +log "$MAVEN_PROJECTBASEDIR" + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" +if [ -r "$wrapperJarPath" ]; then + log "Found $wrapperJarPath" +else + log "Couldn't find $wrapperJarPath, downloading it ..." + + if [ -n "$MVNW_REPOURL" ]; then + wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + else + wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + fi + while IFS="=" read -r key value; do + # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) + safeValue=$(echo "$value" | tr -d '\r') + case "$key" in (wrapperUrl) wrapperUrl="$safeValue"; break ;; + esac + done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" + log "Downloading from: $wrapperUrl" + + if $cygwin; then + wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") + fi + + if command -v wget > /dev/null; then + log "Found wget ... using wget" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + log "Found curl ... using curl" + [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + else + curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" + fi + else + log "Falling back to using Java to download" + javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" + javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaSource=$(cygpath --path --windows "$javaSource") + javaClass=$(cygpath --path --windows "$javaClass") + fi + if [ -e "$javaSource" ]; then + if [ ! -e "$javaClass" ]; then + log " - Compiling MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/javac" "$javaSource") + fi + if [ -e "$javaClass" ]; then + log " - Running MavenWrapperDownloader.java ..." + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +# If specified, validate the SHA-256 sum of the Maven wrapper jar file +wrapperSha256Sum="" +while IFS="=" read -r key value; do + case "$key" in (wrapperSha256Sum) wrapperSha256Sum=$value; break ;; + esac +done < "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" +if [ -n "$wrapperSha256Sum" ]; then + wrapperSha256Result=false + if command -v sha256sum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + elif command -v shasum > /dev/null; then + if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c > /dev/null 2>&1; then + wrapperSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." + echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." + exit 1 + fi + if [ $wrapperSha256Result = false ]; then + echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 + echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 + echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 + exit 1 + fi +fi + +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$JAVA_HOME" ] && + JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") + [ -n "$CLASSPATH" ] && + CLASSPATH=$(cygpath --path --windows "$CLASSPATH") + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +# shellcheck disable=SC2086 # safe args +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..95ba6f5 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,205 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.2.0 +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.2.0/maven-wrapper-3.2.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %WRAPPER_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file +SET WRAPPER_SHA_256_SUM="" +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B +) +IF NOT %WRAPPER_SHA_256_SUM%=="" ( + powershell -Command "&{"^ + "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ + "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ + " Write-Output 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ + " Write-Output 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ + " Write-Output 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ + " exit 1;"^ + "}"^ + "}" + if ERRORLEVEL 1 goto error +) + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f05563c --- /dev/null +++ b/pom.xml @@ -0,0 +1,288 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + com.example + homeutils + 1.0.1-SNAPSHOT + homeutils + homeutils + + 17 + 17 + 17 + + + UTF-8 + UTF-8 + + -Dfile.encoding=UTF-8 + + 17.0.15 + + 2.0.1 + 12.4.0 + + + + + + sonatype-snapshots + https://s01.oss.sonatype.org/content/repositories/snapshots/ + true + + + + + + + com.alibaba + fastjson + 2.0.57 + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-logging + + + + + + com.opencsv + opencsv + 5.2 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.projectlombok + lombok + provided + + + + ch.qos.logback + logback-classic + 1.2.12 + + + + + org.apache.logging.log4j + log4j-to-slf4j + 2.20.0 + + + + com.alibaba + easyexcel + 3.1.1 + + + org.apache.logging.log4j + log4j-core + + + org.slf4j + slf4j-log4j12 + + + + + + + org.openjfx + javafx-controls + ${javafx.version} + + + org.openjfx + javafx-web + ${javafx.version} + + + org.openjfx + javafx-fxml + ${javafx.version} + + + org.openjfx + javafx-base + ${javafx.version} + + + org.openjfx + javafx-graphics + ${javafx.version} + + + org.openjfx + javafx-swing + ${javafx.version} + + + + org.kordamp.ikonli + ikonli-javafx + ${ikonli.version} + + + org.kordamp.ikonli + ikonli-feather-pack + ${ikonli.version} + + + + + io.github.mkpaz + atlantafx-base + ${atlantafx.version} + + + + + net.java.dev.jna + jna + 5.14.0 + + + net.java.dev.jna + jna-platform + 5.14.0 + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + true + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.example.homeutils.HomeUtilsApplication + + + org.projectlombok + lombok + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.2.0 + + + copy-start-scripts + package + copy-resources + + ${project.build.directory} + + + ${basedir}/scripts + + start.sh + + + + + + + + + + + + + + windows-package + + windows.package + + + + + com.akathist.maven.plugins.launch4j + launch4j-maven-plugin + 2.5.3 + + + l4j-exe + package + launch4j + + gui + ${project.build.directory}/${project.build.finalName}.jar + ${project.build.directory}/homeutils.exe + ${project.basedir}/src/main/resources/app_icon.ico + false + + org.springframework.boot.loader.JarLauncher + + + jre + 17 + -Dfile.encoding=UTF-8 + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.2.0 + + + copy-windows-start-script + package + copy-resources + + ${project.build.directory} + + + ${basedir}/scripts + start.bat + + + + + + + + + + + + diff --git a/src/main/java/com/example/homeutils/HomeUtilsApplication.java b/src/main/java/com/example/homeutils/HomeUtilsApplication.java new file mode 100644 index 0000000..06b48e9 --- /dev/null +++ b/src/main/java/com/example/homeutils/HomeUtilsApplication.java @@ -0,0 +1,263 @@ +package com.example.homeutils; + +import com.example.homeutils.system.config.*; +import com.example.homeutils.system.plugin.Plugin; +import com.example.homeutils.system.plugin.PluginDescriptor; +import com.example.homeutils.system.plugin.PluginManager; +import com.example.homeutils.system.plugin.VisualPluginFactory; +import com.example.homeutils.system.support.JfxBootstrap; +import com.example.homeutils.system.support.SystemOpenHelper; +import com.example.homeutils.system.support.WinConsole; +import com.example.homeutils.system.ui.MainWindow; +import com.example.homeutils.system.ui.Theme; +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 org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; + +import java.util.*; +import java.util.concurrent.CountDownLatch; + +@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +@Slf4j +public class HomeUtilsApplication implements CommandLineRunner { + + static { + // 消除 JavaFX classpath 运行时的 WARNING: Unsupported JavaFX configuration + java.util.logging.Logger rootLogger = java.util.logging.Logger.getLogger(""); + rootLogger.setFilter(record -> + !record.getMessage().contains("Unsupported JavaFX configuration")); + } + + private final PluginManager pluginManager; + private final HomeUtilsProperties properties; + private final SettingsService settingsService; + + /** 主窗口引用,供内嵌插件面板使用 */ + private MainWindow mainWindow; + /** 系统设置窗口缓存(内嵌模式复用) */ + private SettingsWindow settingsWindow; + + public HomeUtilsApplication(PluginManager pluginManager, HomeUtilsProperties properties, + SettingsService settingsService) { + this.pluginManager = pluginManager; + this.properties = properties; + this.settingsService = settingsService; + } + + // ==================== 启动诊断 ==================== + + /** + * 应用就绪后验证所有 data/ 配置文件状态 + */ + @EventListener(ApplicationReadyEvent.class) + public void onReady() { + String[] files = { + "project_paths.txt", "datasources.json", + "damaged_repos.cache", "mybatis-generator.properties", + "homeutils.config", "plugins.config" + }; + java.nio.file.Path dataDir = properties.getHomeutilsConfigFilePath().getParent(); + StringBuilder sb = new StringBuilder("data 目录文件状态:\n"); + for (String f : files) { + java.nio.file.Path p = dataDir.resolve(f); + try { + if (java.nio.file.Files.exists(p)) { + long size = java.nio.file.Files.size(p); + sb.append(String.format(" %-35s %s%n", f, + size > 0 ? size + " bytes ✓" : "0 bytes ✗ 空文件!")); + } else { + sb.append(String.format(" %-35s 不存在 ✗%n", f)); + } + } catch (Exception e) { + sb.append(String.format(" %-35s 读取失败: %s%n", f, e.getMessage())); + } + } + log.info(sb.toString()); + } + + public static void main(String[] args) { + SystemOpenHelper.initialize(); + WinConsole.ensureUtf8(); + SpringApplication.run(HomeUtilsApplication.class, args); + } + + @Override + public void run(String... args) throws Exception { + if (args.length == 0) { + showMainWindow(); + } else { + processCommand(args); + } + } + + // ==================== 主窗口入口 ==================== + + private void showMainWindow() throws InterruptedException { + JfxBootstrap.await(); + if (JfxBootstrap.isFailed()) { + System.err.println("JavaFX 初始化失败,程序退出。"); + return; + } + + // 初始化亮色主题 + Platform.runLater(Theme::initTheme); + + // 设置导航栏刷新回调(插件管理中启用/禁用后刷新侧边栏) + settingsService.setNavItemsRefresher(ignoredItems -> Platform.runLater(() -> { + if (mainWindow != null) { + mainWindow.refreshNavItems(buildNavItems()); + } + })); + + List navItems = buildNavItems(); + + CountDownLatch windowClosed = new CountDownLatch(1); + Platform.runLater(() -> { + try { + mainWindow = new MainWindow(navItems, properties, this::openSettingsEmbedded); + 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 + )); + } + + 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 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]; + plugin.executeCommand(toolArgs); + } catch (Exception e) { + log.error("命令执行失败: {} [{}]", plugin.getName(), plugin.getId(), e); + System.err.println("命令执行失败: " + e.getMessage()); + } + } else { + System.err.println("未知命令: " + command); + } + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/WechatAiPlugin.java b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiPlugin.java new file mode 100644 index 0000000..370d78f --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiPlugin.java @@ -0,0 +1,78 @@ +package com.example.homeutils.biz.wechatai; + +import com.example.homeutils.biz.wechatai.config.WechatAiProperties; +import com.example.homeutils.system.plugin.Plugin; +import com.example.homeutils.system.plugin.PluginContext; +import com.example.homeutils.system.plugin.PluginDescriptor; +import com.example.homeutils.system.plugin.VisualPluginFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * 微信 AI 助手插件 —— 通过 ADB 连接安卓手机微信, + * 读取聊天记录、监测指定联系人的新消息,并按用户配置的话术由大模型自动/半自动回复。 + */ +@Component +public class WechatAiPlugin implements Plugin { + + private static final Logger log = LoggerFactory.getLogger(WechatAiPlugin.class); + + private WechatAiProperties properties; + private WechatAiService service; + private WechatAiWindow window; + + @Override + public PluginDescriptor getDescriptor() { + return PluginDescriptor.builder() + .id("wechat-ai") + .name("微信AI助手") + .version("1.0.0") + .description("ADB 连接安卓微信:读取聊天记录、监测指定联系人消息、AI 按话术自动回复") + .icon("💬") + .order(1) + .build(); + } + + @Override + public void onLoad(PluginContext context) { + properties = WechatAiProperties.defaults(); + properties.load(); + service = new WechatAiService(properties); + // 注意:JavaFX 窗口延迟到 getVisualFactory 时才创建, + // 因为插件 onLoad 发生在 JavaFX Toolkit 初始化之前, + // 此时创建 JavaFX Control 会抛 Toolkit not initialized。 + log.info("微信AI助手插件已加载"); + } + + @Override + public void onUnload() { + if (service != null) { + service.shutdown(); + } + log.info("微信AI助手插件已卸载"); + } + + @Override + public String getCommandName() { + return "wechat-ai"; + } + + @Override + public void executeCommand(String... args) throws Exception { + System.out.println("微信AI助手以图形界面使用:打开应用后在侧边栏点击「微信AI助手」。"); + System.out.println("命令行模式仅支持: adb 路径配置(--adb )"); + if (args != null && args.length >= 2 && "--adb".equals(args[0])) { + properties.setAdbPath(args[1]); + System.out.println("已设置 adb 路径: " + args[1]); + } + } + + @Override + public VisualPluginFactory getVisualFactory() { + if (window == null) { + window = new WechatAiWindow(service); + } + return window; + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/WechatAiService.java b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiService.java new file mode 100644 index 0000000..ea9172a --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiService.java @@ -0,0 +1,436 @@ +package com.example.homeutils.biz.wechatai; + +import com.example.homeutils.biz.wechatai.adb.AdbClient; +import com.example.homeutils.biz.wechatai.adb.AdbDevice; +import com.example.homeutils.biz.wechatai.ai.AiConfig; +import com.example.homeutils.biz.wechatai.ai.AiResponder; +import com.example.homeutils.biz.wechatai.ai.ReplyTemplate; +import com.example.homeutils.biz.wechatai.config.WechatAiProperties; +import com.example.homeutils.biz.wechatai.wechat.MessageMonitor; +import com.example.homeutils.biz.wechatai.wechat.WechatMessage; +import com.example.homeutils.biz.wechatai.wechat.WechatSession; +import com.example.homeutils.biz.wechatai.wechat.WechatSessionReader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * 微信 AI 助手编排服务 —— 组装 AdbClient / Reader / Monitor / AiResponder, + * 提供设备、微信、聊天、监测、AI 回复等能力,并把事件回调给 UI。 + *

+ * 线程约定:异步任务在内部线程池执行,所有 listener 回调都在 worker 线程; + * UI 侧注册 listener 时应自行包装 {@code Platform.runLater}。 + */ +public class WechatAiService { + + private static final Logger log = LoggerFactory.getLogger(WechatAiService.class); + + /** UI 事件回调 */ + public interface Listener { + default void onLog(String level, String message) { + } + + default void onDevicesUpdated(List devices, String error) { + } + + default void onWechatStatus(boolean foreground) { + } + + default void onChatListLoaded(List titles, String error) { + } + + default void onChatRead(WechatSession session) { + } + + default void onNewMessages(WechatSession session, List fresh, Path screenshot) { + } + + default void onAiDraft(AiResponder.AiReply draft, String contactName) { + } + + default void onReplySent(boolean success, String message) { + } + + default void onMonitorStatus(boolean running) { + } + + default void onMonitorError(String message) { + } + } + + private final WechatAiProperties properties; + private final List listeners = new ArrayList<>(); + + private volatile AdbClient adb; + private volatile WechatSessionReader reader; + private volatile MessageMonitor monitor; + private final AiResponder aiResponder = new AiResponder(); + private final ExecutorService executor = Executors.newCachedThreadPool(r -> { + Thread t = new Thread(r, "wechat-ai-worker"); + t.setDaemon(true); + return t; + }); + + private volatile String selectedSerial = ""; + + public WechatAiService(WechatAiProperties properties) { + this.properties = properties; + initAdb(); + } + + private void initAdb() { + String adbPath = properties.getAdbPath(); + this.adb = new AdbClient(adbPath.isBlank() ? null : adbPath); + this.reader = new WechatSessionReader(adb); + this.monitor = new MessageMonitor(adb, reader); + } + + public void addListener(Listener listener) { + listeners.add(listener); + } + + public WechatAiProperties properties() { + return properties; + } + + public AdbClient adb() { + return adb; + } + + public String selectedSerial() { + return selectedSerial; + } + + public void setSelectedSerial(String serial) { + this.selectedSerial = serial == null ? "" : serial; + } + + // ==================== 设备 ==================== + + public void refreshDevices() { + executor.submit(() -> { + try { + List devices = adb.listDevices(); + if (devices.isEmpty()) { + log("WARN", "未检测到设备,请确认手机已开启 USB 调试并通过 USB 连接"); + } + listeners.forEach(l -> l.onDevicesUpdated(devices, null)); + } catch (IOException e) { + log("ERROR", "设备枚举失败: " + e.getMessage()); + listeners.forEach(l -> l.onDevicesUpdated(List.of(), e.getMessage())); + } + }); + } + + public void connectDevice(String hostPort) { + executor.submit(() -> { + try { + String err = adb.connect(hostPort); + if (err == null) { + log("INFO", "已连接设备 " + hostPort); + } else { + log("ERROR", "连接失败: " + err); + listeners.forEach(l -> l.onDevicesUpdated(List.of(), "连接失败: " + err)); + } + refreshDevices(); + } catch (IOException e) { + log("ERROR", "连接异常: " + e.getMessage()); + } + }); + } + + // ==================== 微信 ==================== + + public void launchWechat() { + requireSerial(); + executor.submit(() -> { + try { + boolean ok = reader.launchWechat(selectedSerial); + log(ok ? "INFO" : "WARN", ok ? "已启动手机微信" : "启动微信命令已执行,请确认结果"); + Thread.sleep(1200); + checkWechatForeground(); + } catch (Exception e) { + log("ERROR", "启动微信失败: " + e.getMessage()); + } + }); + } + + public void checkWechatForeground() { + executor.submit(() -> { + if (selectedSerial.isBlank()) { + return; + } + try { + boolean fg = reader.isWechatForeground(selectedSerial); + listeners.forEach(l -> l.onWechatStatus(fg)); + } catch (IOException e) { + log("WARN", "检测微信前台状态失败: " + e.getMessage()); + } + }); + } + + // ==================== 聊天记录 ==================== + + public void refreshChatList() { + requireSerial(); + executor.submit(() -> { + try { + List titles = reader.readChatList(selectedSerial); + listeners.forEach(l -> l.onChatListLoaded(titles, null)); + log("INFO", "已读取会话列表 " + titles.size() + " 项"); + } catch (IOException e) { + log("ERROR", "读取会话列表失败: " + e.getMessage()); + listeners.forEach(l -> l.onChatListLoaded(List.of(), e.getMessage())); + } + }); + } + + public void readCurrentChat() { + requireSerial(); + executor.submit(() -> { + try { + WechatSession session = reader.readCurrentChat(selectedSerial); + listeners.forEach(l -> l.onChatRead(session)); + } catch (IOException e) { + log("ERROR", "读取聊天记录失败: " + e.getMessage()); + } + }); + } + + public void openChatByTitle(String title) { + requireSerial(); + executor.submit(() -> { + try { + boolean ok = reader.openChat(selectedSerial, title); + log(ok ? "INFO" : "WARN", ok ? "已进入会话 " + title : "未找到会话 " + title); + Thread.sleep(800); + readCurrentChat(); + } catch (Exception e) { + log("ERROR", "打开会话失败: " + e.getMessage()); + } + }); + } + + /** 上滑加载更早的历史消息(当前界面) */ + public void loadMoreHistory() { + requireSerial(); + executor.submit(() -> { + try { + reader.scrollUp(selectedSerial); + Thread.sleep(800); + readCurrentChat(); + } catch (Exception e) { + log("ERROR", "加载历史消息失败: " + e.getMessage()); + } + }); + } + + /** 异步截图并展示(截图保存后回调日志与 UI 截图预览) */ + public void captureScreenshotAsync() { + requireSerial(); + executor.submit(() -> { + try { + Path shot = reader.captureScreen(selectedSerial, + Path.of("data", "wechat-ai", "screenshots")); + log("INFO", "截图已保存: " + shot); + listeners.forEach(l -> l.onNewMessages(null, List.of(), shot)); + } catch (Exception e) { + log("ERROR", "截图失败: " + e.getMessage()); + } + }); + } + + /** 配置变更后同步监测器轮询间隔 */ + public void updateMonitorInterval() { + monitor.setInterval(properties.getMonitorIntervalMs()); + } + + // ==================== 监测 ==================== + + public void startMonitor(String contactName) { + if (selectedSerial.isBlank()) { + log("ERROR", "请先选择设备"); + return; + } + if (contactName == null || contactName.isBlank()) { + log("ERROR", "请先指定要监测的联系人"); + return; + } + monitor.setTarget(selectedSerial, contactName); + monitor.setInterval(properties.getMonitorIntervalMs()); + monitor.setListener(new MessageMonitor.MessageMonitorListener() { + @Override + public void onNewMessages(WechatSession session, List fresh, Path screenshot) { + notifyNewMessages(session, fresh, screenshot); + } + + @Override + public void onMonitorError(String message) { + listeners.forEach(l -> l.onMonitorError(message)); + } + + @Override + public void onStatusChanged(boolean running) { + listeners.forEach(l -> l.onMonitorStatus(running)); + } + }); + monitor.start(); + } + + public void stopMonitor() { + monitor.stop(); + } + + public boolean isMonitorRunning() { + return monitor.isRunning(); + } + + // ==================== AI 回复 ==================== + + /** + * 为某条/某批新消息生成回复草稿(异步),生成完成后回调 onAiDraft。 + * 若 autoReply 为 true,生成后直接发送。 + */ + public void generateReply(String contactName, WechatSession session, List fresh, + Path screenshot, boolean autoReply) { + executor.submit(() -> { + AiConfig config = properties.getAiConfig(); + String persona = properties.getPersona(); + List templates = properties.getTemplates(); + try { + AiResponder.AiReply reply = aiResponder.generateReply( + config, persona, templates, fresh, session.messages(), screenshot); + log("INFO", "[" + reply.source() + "] " + truncate(reply.text(), 120)); + if (autoReply) { + sendReply(contactName, reply.text()); + } else { + listeners.forEach(l -> l.onAiDraft(reply, contactName)); + } + } catch (IOException e) { + log("ERROR", "AI 生成失败: " + e.getMessage()); + } + }); + } + + /** 在手机上输入并发送消息(需已在目标聊天页) */ + public void sendReply(String contactName, String text) { + if (selectedSerial.isBlank()) { + log("ERROR", "请先选择设备"); + listeners.forEach(l -> l.onReplySent(false, "请先选择设备")); + return; + } + if (contactName == null || contactName.isBlank()) { + log("ERROR", "未指定联系人"); + listeners.forEach(l -> l.onReplySent(false, "未指定联系人")); + return; + } + executor.submit(() -> { + try { + doSend(contactName, text); + listeners.forEach(l -> l.onReplySent(true, "已发送给 " + contactName)); + } catch (Exception e) { + log("ERROR", "发送失败: " + e.getMessage()); + listeners.forEach(l -> l.onReplySent(false, "发送失败: " + e.getMessage())); + } + }); + } + + private void doSend(String contactName, String text) throws Exception { + // 1) 确保在目标聊天页 + WechatSession session = reader.readCurrentChat(selectedSerial); + if (!session.title().equals(contactName)) { + if (!reader.openChat(selectedSerial, contactName)) { + throw new IOException("未找到联系人 " + contactName + " 的会话,请先手动打开聊天"); + } + Thread.sleep(1000); + } + // 2) 定位输入框(EditText)并聚焦 + List nodes = adb.dumpUiNodes(selectedSerial); + AdbClient.UiNode input = null; + for (AdbClient.UiNode n : nodes) { + if ("android.widget.EditText".equals(n.className())) { + input = n; + break; + } + } + if (input == null) { + throw new IOException("未找到聊天输入框(可能不在聊天页)"); + } + adb.tap(selectedSerial, input.centerX(), input.centerY()); + Thread.sleep(400); + // 3) 输入文本 + adb.inputText(selectedSerial, text); + Thread.sleep(400); + // 4) 点击发送按钮 + nodes = adb.dumpUiNodes(selectedSerial); + AdbClient.UiNode sendBtn = null; + for (AdbClient.UiNode n : nodes) { + if (n.displayText() != null && "发送".equals(n.displayText().trim())) { + sendBtn = n; + break; + } + } + if (sendBtn == null) { + // 输入法里可能没有"发送",尝试回车键 + adb.shell(selectedSerial, "input", "keyevent", "66"); + log("WARN", "未找到发送按钮,已尝试回车发送"); + } else { + adb.tap(selectedSerial, sendBtn.centerX(), sendBtn.centerY()); + } + Thread.sleep(800); + // 5) 重置监测基线,避免把自己发的消息当作新消息 + monitor.resetBaseline(); + log("INFO", "回复已发送: " + truncate(text, 60)); + } + + // ==================== 新消息处理(由 Monitor 回调) ==================== + + private void notifyNewMessages(WechatSession session, List fresh, Path screenshot) { + log("INFO", "检测到新消息: " + (fresh == null ? "" : fresh.size()) + " 条"); + // 先通知 UI 展示 + listeners.forEach(l -> l.onNewMessages(session, fresh, screenshot)); + // 自动/半自动处理 + if (properties.isAutoReplyEnabled()) { + generateReply(session.title(), session, fresh, screenshot, true); + } else { + generateReply(session.title(), session, fresh, screenshot, false); + } + } + + // ==================== 工具 ==================== + + private void requireSerial() { + if (selectedSerial.isBlank()) { + log("ERROR", "请先选择设备"); + throw new IllegalStateException("未选择设备"); + } + } + + private void log(String level, String message) { + switch (level) { + case "ERROR" -> log.error(message); + case "WARN" -> log.warn(message); + default -> log.info(message); + } + listeners.forEach(l -> l.onLog(level, message)); + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max) + "…"; + } + + /** 关闭线程池(插件卸载时调用) */ + public void shutdown() { + monitor.stop(); + executor.shutdownNow(); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/WechatAiWindow.java b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiWindow.java new file mode 100644 index 0000000..e7dc2ac --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/WechatAiWindow.java @@ -0,0 +1,797 @@ +package com.example.homeutils.biz.wechatai; + +import atlantafx.base.theme.Styles; +import com.example.homeutils.biz.wechatai.adb.AdbClient; +import com.example.homeutils.biz.wechatai.adb.AdbDevice; +import com.example.homeutils.biz.wechatai.ai.AiConfig; +import com.example.homeutils.biz.wechatai.ai.AiResponder; +import com.example.homeutils.biz.wechatai.ai.ReplyTemplate; +import com.example.homeutils.biz.wechatai.config.WechatAiProperties; +import com.example.homeutils.biz.wechatai.wechat.WechatMessage; +import com.example.homeutils.biz.wechatai.wechat.WechatSession; +import com.example.homeutils.system.plugin.VisualPluginFactory; +import com.example.homeutils.system.ui.StyledButton; +import com.example.homeutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.Separator; +import javafx.scene.control.SplitPane; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 微信 AI 助手主窗口(内嵌面板 + 独立窗口双模式)。 + *

+ * 布局:顶部工具栏(设备/微信/监控/模式)+ 状态徽标行 + 中央分栏 + * (左侧聊天记录与截图,右侧联系人/话术与AI/监控日志三个页签)。 + */ +public class WechatAiWindow implements VisualPluginFactory { + + private static final DateTimeFormatter TIME = DateTimeFormatter.ofPattern("HH:mm:ss"); + + private final WechatAiService service; + private final WechatAiProperties properties; + + // 内嵌面板(懒加载) + private Parent sharedRoot; + + // ==================== 设备与微信 ==================== + private final ComboBox deviceBox = new ComboBox<>(); + private final TextField connectField = new TextField(); + private final Button connectBtn = new Button("连接"); + private final Button refreshDeviceBtn = new Button("刷新"); + private final Button launchWechatBtn = new Button("启动微信"); + private final Button refreshListBtn = new Button("刷新列表"); + private final Button readChatBtn = new Button("读取聊天"); + private final Button loadMoreBtn = new Button("加载更早"); + private final ToggleButton monitorBtn = new ToggleButton("开始监测"); + private final ToggleButton autoModeBtn = new ToggleButton("半自动"); + private final Label deviceBadge = badgeLabel(); + private final Label wechatBadge = badgeLabel(); + private final Label monitorBadge = badgeLabel(); + private final Label aiBadge = badgeLabel(); + + // ==================== 聊天记录 ==================== + private final ListView chatList = new ListView<>(); + private final ImageView screenshotView = new ImageView(); + + // ==================== 缓存页签(避免重复构建) ==================== + private VBox logTabPane; + private VBox draftCard; + + // ==================== 联系人 ==================== + private final ObservableList contacts = FXCollections.observableArrayList(); + private final ListView contactList = new ListView<>(contacts); + private final TextField contactField = new TextField(); + private final Button addContactBtn = new Button("添加"); + private final Button delContactBtn = new Button("移除"); + private final ListView sessionList = new ListView<>(); + private final Button useSessionBtn = new Button("加入联系人"); + + // ==================== 话术与 AI ==================== + private final TextArea personaArea = new TextArea(); + private final Button savePersonaBtn = new Button("保存话术"); + private final ObservableList templates = FXCollections.observableArrayList(); + private final TableView templateTable = new TableView<>(templates); + private final TextField keywordField = new TextField(); + private final TextField replyField = new TextField(); + private final Button addTemplateBtn = new Button("添加模板"); + private final Button delTemplateBtn = new Button("删除模板"); + private final TextField baseUrlField = new TextField(); + private final TextField apiKeyField = new TextField(); + private final TextField modelField = new TextField(); + private final TextField tempField = new TextField(); + private final TextField timeoutField = new TextField(); + private final ToggleButton visionToggle = new ToggleButton("视觉读图"); + private final TextField intervalField = new TextField(); + private final ToggleButton autoReplyToggle = new ToggleButton("全自动回复"); + private final Button saveAiBtn = new Button("保存AI配置"); + + // ==================== 监控与日志 ==================== + private final TextArea logArea = new TextArea(); + private final Button clearLogBtn = new Button("清空日志"); + private final Label draftFrom = new Label(); + private final TextArea draftArea = new TextArea(); + private final Button sendDraftBtn = new Button("发送到手机"); + private final Button discardDraftBtn = new Button("丢弃"); + private final Label statusLabel = new Label("就绪"); + + public WechatAiWindow(WechatAiService service) { + this.service = service; + this.properties = service.properties(); + registerListener(); + } + + // ==================== VisualPluginFactory ==================== + + @Override + public Parent getEmbeddedPane() { + if (sharedRoot == null) { + sharedRoot = buildRoot(); + } + return sharedRoot; + } + + @Override + public void open(Runnable onClosed) { + Parent root = buildRoot(); + Stage stage = new Stage(); + Scene scene = new Scene(root, 1360, 820); + Theme.applyCustomCss(scene); + stage.setTitle("微信AI助手"); + stage.setScene(scene); + stage.setOnHidden(e -> { + if (onClosed != null) { + onClosed.run(); + } + }); + stage.show(); + } + + // ==================== 界面构建 ==================== + + private Parent buildRoot() { + BorderPane root = new BorderPane(); + root.setTop(buildToolbar()); + root.setCenter(buildBody()); + root.setBottom(buildStatusBar()); + // 初始加载 + loadConfigToUi(); + service.refreshDevices(); + return root; + } + + private HBox buildToolbar() { + deviceBox.setPrefWidth(260); + deviceBox.setPromptText("选择设备…"); + deviceBox.getStyleClass().add(Theme.CSS_COMBO); + deviceBox.setCellFactory(lv -> new ListCell<>() { + @Override + protected void updateItem(AdbDevice item, boolean empty) { + super.updateItem(item, empty); + setText(item == null || empty ? null : item.toString()); + } + }); + deviceBox.setOnAction(e -> { + AdbDevice dev = deviceBox.getValue(); + if (dev != null) { + service.setSelectedSerial(dev.serial()); + service.checkWechatForeground(); + } + }); + refreshDeviceBtn.getStyleClass().addAll(Theme.CSS_BTN_SMALL, Styles.ACCENT); + refreshDeviceBtn.setOnAction(e -> service.refreshDevices()); + + connectField.setPromptText("无线: 192.168.x.x:5555"); + connectField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + connectField.setPrefWidth(170); + connectBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + connectBtn.setOnAction(e -> { + String host = connectField.getText().trim(); + if (!host.isEmpty()) { + service.connectDevice(host); + } + }); + + launchWechatBtn.getStyleClass().addAll(Theme.CSS_BTN_SMALL, Styles.ACCENT); + launchWechatBtn.setOnAction(e -> service.launchWechat()); + + monitorBtn.setSelected(false); + monitorBtn.getStyleClass().add(Theme.CSS_TOGGLE_BTN); + monitorBtn.setText("开始监测"); + monitorBtn.setOnAction(e -> { + if (monitorBtn.isSelected()) { + String target = selectedMonitorTarget(); + if (target == null) { + monitorBtn.setSelected(false); + appendLog("WARN", "请先在「联系人」页签添加并选择一个要监测的联系人"); + return; + } + monitorBtn.setDisable(true); + service.startMonitor(target); + } else { + service.stopMonitor(); + } + }); + + autoModeBtn.setSelected(!properties.isAutoReplyEnabled()); + autoModeBtn.getStyleClass().add(Theme.CSS_TOGGLE_BTN); + autoModeBtn.setText(properties.isAutoReplyEnabled() ? "全自动" : "半自动"); + autoModeBtn.setOnAction(e -> { + boolean auto = !autoModeBtn.isSelected(); + autoModeBtn.setText(auto ? "全自动" : "半自动"); + properties.setAutoReplyEnabled(auto); + appendLog("INFO", "回复模式已切换为: " + (auto ? "全自动(AI直接发送)" : "半自动(人工确认后发送)")); + }); + + HBox box = new HBox(Theme.S3, + titleLabel(), new Separator(javafx.geometry.Orientation.VERTICAL), + new Label("设备"), deviceBox, refreshDeviceBtn, connectField, connectBtn, + launchWechatBtn, + new Separator(javafx.geometry.Orientation.VERTICAL), + monitorBtn, autoModeBtn); + box.getStyleClass().add("wx-toolbar"); + return box; + } + + private Label titleLabel() { + Label title = new Label("微信AI助手"); + title.getStyleClass().add(Theme.CSS_PAGE_TITLE); + title.setFont(Theme.pageTitle()); + return title; + } + + private HBox buildStatusBar() { + HBox badges = new HBox(Theme.S3, + statusBadge("设备", deviceBadge, "offline"), + statusBadge("微信", wechatBadge, "offline"), + statusBadge("监测", monitorBadge, "offline"), + statusBadge("AI", aiBadge, "offline")); + badges.setPadding(new Insets(Theme.S2, Theme.S4, Theme.S2, Theme.S4)); + badges.setAlignment(Pos.CENTER_LEFT); + + statusLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); + HBox right = new HBox(statusLabel); + right.setAlignment(Pos.CENTER_RIGHT); + right.setPadding(new Insets(0, Theme.S4, 0, 0)); + HBox.setHgrow(right, Priority.ALWAYS); + + HBox bar = new HBox(badges, right); + bar.getStyleClass().add("status-bar"); + return bar; + } + + private HBox statusBadge(String name, Label dot, String state) { + Label badge = new Label(name); + badge.getStyleClass().add("wx-badge-text"); + HBox box = new HBox(dot, badge); + box.getStyleClass().add("wx-badge"); + return box; + } + + private static Label badgeLabel() { + Label dot = new Label(); + dot.getStyleClass().addAll("wx-badge-dot", "offline"); + return dot; + } + + private void setBadge(Label dot, String state) { + dot.getStyleClass().removeAll("online", "offline", "error", "warning"); + dot.getStyleClass().add(state); + } + + private SplitPane buildBody() { + // 左栏:聊天记录 + 截图 + VBox left = buildLeftPanel(); + // 右栏:TabPane + TabPane tabs = buildTabs(); + tabs.getStyleClass().add("wx-config-tab"); + + SplitPane split = new SplitPane(left, tabs); + split.setDividerPositions(0.46); + SplitPane.setResizableWithParent(left, true); + return split; + } + + private VBox buildLeftPanel() { + // 会话列表 + Label listTitle = new Label("聊天记录"); + listTitle.getStyleClass().add(Theme.CSS_SECTION_TITLE); + HBox opRow = new HBox(Theme.S2, + refreshListBtn, readChatBtn, loadMoreBtn); + refreshListBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + refreshListBtn.setOnAction(e -> service.refreshChatList()); + readChatBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + readChatBtn.setOnAction(e -> service.readCurrentChat()); + loadMoreBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + loadMoreBtn.setOnAction(e -> service.loadMoreHistory()); + + chatList.getStyleClass().addAll("wx-chat-list", "compact-list"); + chatList.setPlaceholder(new Label("暂无聊天记录\n点击「读取聊天」获取当前界面消息")); + + // 截图 + Label shotTitle = new Label("手机截图"); + shotTitle.getStyleClass().add(Theme.CSS_SECTION_TITLE); + screenshotView.setFitWidth(340); + screenshotView.setPreserveRatio(true); + screenshotView.setSmooth(true); + StackPane shotPane = new StackPane(screenshotView); + shotPane.setMinHeight(200); + shotPane.setStyle("-fx-background-color: -homeutils-card; -fx-background-radius: 8;"); + shotPane.getChildren().add(new Label("截图预览(点击「截屏」按钮)")); + shotPane.getChildren().setAll(screenshotView); + + Button shotBtn = new Button("截屏"); + shotBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + shotBtn.setOnAction(e -> service.captureScreenshotAsync()); + + VBox box = new VBox(Theme.S3, + listTitle, opRow, chatList, + new Separator(javafx.geometry.Orientation.HORIZONTAL), + shotTitle, new HBox(Theme.S2, shotBtn), shotPane); + VBox.setVgrow(chatList, Priority.ALWAYS); + box.setPadding(new Insets(Theme.S4)); + box.setSpacing(Theme.S3); + return box; + } + + private TabPane buildTabs() { + TabPane tabs = new TabPane(); + tabs.getTabs().addAll( + new Tab("联系人", buildContactsTab()), + new Tab("话术与AI", buildAiTab()), + new Tab("监控与日志", logTabPane = buildLogTab())); + return tabs; + } + + // ---- 联系人页签 ---- + + private VBox buildContactsTab() { + contactList.getStyleClass().addAll("wx-chat-list", "compact-list"); + contactList.setPlaceholder(new Label("暂无指定联系人")); + + HBox addRow = new HBox(Theme.S2, contactField, addContactBtn); + contactField.setPromptText("输入联系人名称(与微信昵称一致)"); + contactField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + HBox.setHgrow(contactField, Priority.ALWAYS); + addContactBtn.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + addContactBtn.setOnAction(e -> addContact()); + delContactBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + delContactBtn.setOnAction(e -> removeSelectedContact()); + contactField.setOnAction(e -> addContact()); + + Label sessionTitle = new Label("手机会话列表(刷新后点击选中)"); + sessionTitle.getStyleClass().add(Theme.CSS_SECTION_TITLE); + sessionList.getStyleClass().addAll("wx-chat-list", "compact-list"); + sessionList.setPlaceholder(new Label("点击「刷新列表」读取手机上的会话")); + useSessionBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + useSessionBtn.setOnAction(e -> { + String selected = sessionList.getSelectionModel().getSelectedItem(); + if (selected != null && !selected.isBlank() && !contacts.contains(selected)) { + contacts.add(selected); + properties.setContacts(List.copyOf(contacts)); + appendLog("INFO", "已把 [" + selected + "] 加入指定联系人"); + } + }); + + VBox box = new VBox(Theme.S3, + sectionTitle("指定联系人(AI 只监测这些人)"), addRow, + new HBox(Theme.S2, delContactBtn), contactList, + new Separator(javafx.geometry.Orientation.HORIZONTAL), + sessionTitle, new HBox(Theme.S2, refreshListBtn, useSessionBtn), sessionList); + VBox.setVgrow(contactList, Priority.ALWAYS); + VBox.setVgrow(sessionList, Priority.ALWAYS); + box.setPadding(new Insets(Theme.S4)); + box.setSpacing(Theme.S3); + return box; + } + + private void addContact() { + String name = contactField.getText().trim(); + if (name.isEmpty()) { + return; + } + if (!contacts.contains(name)) { + contacts.add(name); + properties.setContacts(List.copyOf(contacts)); + appendLog("INFO", "已添加联系人: " + name); + } + contactField.clear(); + } + + private void removeSelectedContact() { + String selected = contactList.getSelectionModel().getSelectedItem(); + if (selected != null) { + contacts.remove(selected); + properties.setContacts(List.copyOf(contacts)); + appendLog("INFO", "已移除联系人: " + selected); + } + } + + private String selectedMonitorTarget() { + String selected = contactList.getSelectionModel().getSelectedItem(); + if (selected != null && !selected.isBlank()) { + return selected; + } + return contacts.isEmpty() ? null : contacts.get(0); + } + + // ---- 话术与 AI 页签 ---- + + private VBox buildAiTab() { + personaArea.setPromptText("例如:你是小明,性格外向,回答简短随意,喜欢用表情。\n不要透露你在替别人回复,不要自称 AI。"); + personaArea.getStyleClass().add(Theme.CSS_TEXT_AREA); + personaArea.setPrefRowCount(4); + savePersonaBtn.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + savePersonaBtn.setOnAction(e -> { + properties.setPersona(personaArea.getText()); + appendLog("INFO", "人设描述已保存"); + }); + + // 固定模板表 + templateTable.getStyleClass().add(Theme.CSS_TABLE); + templateTable.setPrefHeight(180); + TableColumn kwCol = new TableColumn<>("关键词"); + kwCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().keyword())); + kwCol.setPrefWidth(140); + TableColumn rpCol = new TableColumn<>("固定回复"); + rpCol.setCellValueFactory(d -> new SimpleStringProperty(d.getValue().reply())); + rpCol.setPrefWidth(280); + templateTable.getColumns().addAll(kwCol, rpCol); + + keywordField.setPromptText("命中关键词"); + keywordField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + replyField.setPromptText("固定回复内容"); + replyField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + HBox.setHgrow(replyField, Priority.ALWAYS); + addTemplateBtn.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + addTemplateBtn.setOnAction(e -> addTemplate()); + delTemplateBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + delTemplateBtn.setOnAction(e -> { + ReplyTemplate t = templateTable.getSelectionModel().getSelectedItem(); + if (t != null) { + templates.remove(t); + properties.setTemplates(List.copyOf(templates)); + } + }); + keywordField.setOnAction(e -> addTemplate()); + replyField.setOnAction(e -> addTemplate()); + + // 模型配置 + baseUrlField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + apiKeyField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + modelField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + tempField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + timeoutField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + intervalField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + + visionToggle.getStyleClass().add(Theme.CSS_TOGGLE_BTN); + autoReplyToggle.getStyleClass().add(Theme.CSS_TOGGLE_BTN); + + saveAiBtn.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + saveAiBtn.setOnAction(e -> saveAiConfig()); + + VBox aiCard = new VBox(Theme.S3, + sectionTitle("人设与话术"), + personaArea, + new HBox(Theme.S2, savePersonaBtn), + sectionTitle("固定回复模板(关键词命中优先于 AI 生成)"), + templateTable, + new HBox(Theme.S2, keywordField, replyField, addTemplateBtn, delTemplateBtn), + sectionTitle("AI 模型配置(OpenAI 兼容接口)"), + formRow("Base URL", baseUrlField), + formRow("API Key", apiKeyField), + formRow("模型", modelField), + new HBox(Theme.S3, formRow("温度", tempField), formRow("超时(秒)", timeoutField)), + new HBox(Theme.S3, formRow("轮询间隔(ms)", intervalField), visionToggle), + new HBox(Theme.S3, autoReplyToggle, saveAiBtn)); + aiCard.setPadding(new Insets(Theme.S4)); + aiCard.setSpacing(Theme.S3); + return aiCard; + } + + private void addTemplate() { + String kw = keywordField.getText().trim(); + String rp = replyField.getText().trim(); + if (kw.isEmpty() || rp.isEmpty()) { + return; + } + templates.add(new ReplyTemplate(kw, rp)); + properties.setTemplates(List.copyOf(templates)); + keywordField.clear(); + replyField.clear(); + appendLog("INFO", "已添加固定模板: [" + kw + "] -> " + rp); + } + + private void saveAiConfig() { + try { + AiConfig config = new AiConfig( + baseUrlField.getText().trim(), + apiKeyField.getText().trim(), + modelField.getText().trim(), + parseDouble(tempField.getText(), 0.7), + (int) parseDouble(timeoutField.getText(), 30), + visionToggle.isSelected()); + properties.setAiConfig(config); + long interval = (long) parseDouble(intervalField.getText(), 3000); + properties.setMonitorIntervalMs(Math.max(1000, interval)); + properties.setAutoReplyEnabled(autoReplyToggle.isSelected()); + service.updateMonitorInterval(); + appendLog("INFO", "AI 配置已保存"); + } catch (NumberFormatException e) { + appendLog("ERROR", "温度/超时/间隔必须是数字"); + } + } + + private static double parseDouble(String text, double def) { + if (text == null || text.isBlank()) { + return def; + } + return Double.parseDouble(text.trim()); + } + + private HBox formRow(String name, javafx.scene.Node field) { + Label label = new Label(name); + label.getStyleClass().add("wx-form-label"); + HBox row = new HBox(Theme.S2, label, field); + row.getStyleClass().add("wx-form-row"); + HBox.setHgrow(field, Priority.ALWAYS); + return row; + } + + // ---- 监控与日志页签 ---- + + private VBox buildLogTab() { + logArea.setEditable(false); + logArea.getStyleClass().addAll(Theme.CSS_LOG_AREA, "mono-text"); + logArea.setPromptText("监控与 AI 日志"); + VBox.setVgrow(logArea, Priority.ALWAYS); + clearLogBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + clearLogBtn.setOnAction(e -> logArea.clear()); + + // AI 草稿区(半自动模式预览) + draftFrom.getStyleClass().add(Theme.CSS_SECTION_TITLE); + Label draftHint = new Label("AI 已生成回复,确认后发送到手机(半自动模式)"); + draftHint.getStyleClass().add("wx-hint"); + draftArea.setEditable(true); + draftArea.getStyleClass().add(Theme.CSS_TEXT_AREA); + draftArea.setPrefRowCount(3); + sendDraftBtn.getStyleClass().add(Theme.CSS_BTN_SUCCESS); + sendDraftBtn.setOnAction(e -> { + String text = draftArea.getText().trim(); + String contact = draftFrom.getText().replace("回复给 ", "").trim(); + if (text.isEmpty()) { + return; + } + service.sendReply(contact.isEmpty() ? selectedMonitorTarget() : contact, text); + draftArea.clear(); + draftFrom.setText(""); + }); + discardDraftBtn.getStyleClass().add(Theme.CSS_BTN_SMALL); + discardDraftBtn.setOnAction(e -> { + draftArea.clear(); + draftFrom.setText(""); + }); + + draftCard = new VBox(Theme.S2, draftFrom, draftHint, draftArea, + new HBox(Theme.S2, sendDraftBtn, discardDraftBtn)); + draftCard.getStyleClass().add("wx-draft-card"); + draftCard.setVisible(false); + draftCard.setManaged(false); + + VBox box = new VBox(Theme.S3, + sectionTitle("AI 回复草稿"), draftCard, + new HBox(Theme.S2, new Label("运行日志"), new Region(), clearLogBtn), + logArea); + box.setPadding(new Insets(Theme.S4)); + box.setSpacing(Theme.S3); + return box; + } + + private Label sectionTitle(String text) { + Label label = new Label(text); + label.getStyleClass().add(Theme.CSS_SECTION_TITLE); + return label; + } + + // ==================== 配置 → UI ==================== + + private void loadConfigToUi() { + personaArea.setText(properties.getPersona()); + contacts.setAll(properties.getContacts()); + templates.setAll(properties.getTemplates()); + + AiConfig c = properties.getAiConfig(); + baseUrlField.setText(c.baseUrl()); + apiKeyField.setText(c.apiKey()); + modelField.setText(c.model()); + tempField.setText(String.valueOf(c.temperature())); + timeoutField.setText(String.valueOf(c.timeoutSeconds())); + visionToggle.setSelected(c.supportsVision()); + intervalField.setText(String.valueOf(properties.getMonitorIntervalMs())); + autoReplyToggle.setSelected(properties.isAutoReplyEnabled()); + autoModeBtn.setSelected(!properties.isAutoReplyEnabled()); + autoModeBtn.setText(properties.isAutoReplyEnabled() ? "全自动" : "半自动"); + } + + // ==================== 事件监听 ==================== + + private void registerListener() { + service.addListener(new WechatAiService.Listener() { + @Override + public void onLog(String level, String message) { + Platform.runLater(() -> appendLog(level, message)); + } + + @Override + public void onDevicesUpdated(List devices, String error) { + Platform.runLater(() -> updateDevices(devices, error)); + } + + @Override + public void onWechatStatus(boolean foreground) { + Platform.runLater(() -> { + setBadge(wechatBadge, foreground ? "online" : "warning"); + if (foreground) { + statusLabel.setText("微信在前台运行"); + } + }); + } + + @Override + public void onChatListLoaded(List titles, String error) { + Platform.runLater(() -> { + if (error != null) { + appendLog("ERROR", "读取会话列表失败: " + error); + return; + } + sessionList.setItems(FXCollections.observableArrayList(titles)); + appendLog("INFO", "会话列表已更新,共 " + titles.size() + " 项"); + }); + } + + @Override + public void onChatRead(WechatSession session) { + Platform.runLater(() -> showSession(session)); + } + + @Override + public void onNewMessages(WechatSession session, List fresh, Path screenshot) { + Platform.runLater(() -> { + showSession(session); + if (screenshot != null) { + showScreenshot(screenshot); + } + String summary = fresh == null || fresh.isEmpty() + ? "检测到新消息(内容已更新)" + : "检测到 " + fresh.size() + " 条新消息"; + appendLog("INFO", summary + "(会话: " + session.title() + ")"); + }); + } + + @Override + public void onAiDraft(AiResponder.AiReply draft, String contactName) { + Platform.runLater(() -> { + draftFrom.setText("回复给 " + contactName); + draftArea.setText(draft.text()); + draftCardSetVisible(true); + appendLog("INFO", "[" + draft.source() + "] 已生成回复草稿,等待确认发送"); + }); + } + + @Override + public void onReplySent(boolean success, String message) { + Platform.runLater(() -> { + appendLog(success ? "INFO" : "ERROR", message); + if (success) { + draftCardSetVisible(false); + statusLabel.setText(message); + } + }); + } + + @Override + public void onMonitorStatus(boolean running) { + Platform.runLater(() -> { + setBadge(monitorBadge, running ? "online" : "offline"); + monitorBtn.setSelected(running); + monitorBtn.setDisable(false); + monitorBtn.setText(running ? "停止监测" : "开始监测"); + statusLabel.setText(running ? "正在监测指定联系人的新消息" : "监测已停止"); + }); + } + + @Override + public void onMonitorError(String message) { + Platform.runLater(() -> { + appendLog("WARN", message); + setBadge(monitorBadge, "warning"); + }); + } + }); + } + + private void draftCardSetVisible(boolean visible) { + if (draftCard != null) { + draftCard.setVisible(visible); + draftCard.setManaged(visible); + } + } + + private void updateDevices(List devices, String error) { + deviceBox.getItems().setAll(devices); + if (error != null) { + appendLog("ERROR", error); + setBadge(deviceBadge, "error"); + return; + } + setBadge(deviceBadge, devices.isEmpty() ? "offline" : "online"); + statusLabel.setText(devices.isEmpty() + ? "未检测到设备,请连接手机并开启 USB 调试" + : "检测到 " + devices.size() + " 台设备"); + if (devices.isEmpty()) { + appendLog("WARN", "未检测到设备。请确认:1) 手机开启开发者模式与 USB 调试;2) 使用数据线连接;3) 手机弹出授权时点击允许"); + } else { + // 自动选中第一台在线设备 + AdbDevice online = devices.stream().filter(AdbDevice::isOnline).findFirst().orElse(devices.get(0)); + deviceBox.setValue(online); + service.setSelectedSerial(online.serial()); + service.checkWechatForeground(); + } + } + + private void showSession(WechatSession session) { + if (session == null) { + return; + } + ObservableList lines = FXCollections.observableArrayList(); + lines.add("—— 会话: " + (session.title().isEmpty() ? "(未知)" : session.title()) + " ——"); + for (WechatMessage m : session.messages()) { + StringBuilder sb = new StringBuilder(); + if (m.time() != null && !m.time().isBlank()) { + sb.append("[").append(m.time()).append("] "); + } + if (m.sender() != null && !m.sender().isBlank()) { + sb.append(m.sender()).append(": "); + } + sb.append(m.content()); + lines.add(sb.toString()); + } + chatList.setItems(lines); + if (!lines.isEmpty()) { + chatList.scrollTo(lines.size() - 1); + } + } + + private void showScreenshot(Path path) { + try { + Image img = new Image(new File(path.toString()).toURI().toString()); + screenshotView.setImage(img); + } catch (Exception e) { + appendLog("WARN", "截图预览加载失败: " + e.getMessage()); + } + } + + private void appendLog(String level, String message) { + String line = TIME.format(LocalTime.now()) + " [" + level + "] " + message; + logArea.appendText(line + "\n"); + if (logArea.getLength() > 200_000) { + logArea.deleteText(0, 50_000); + } + // 同步到 java 日志(避免中文乱码则直接用 stdout 兜底) + System.out.println("[wechat-ai] " + line); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbClient.java b/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbClient.java new file mode 100644 index 0000000..39bb044 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbClient.java @@ -0,0 +1,439 @@ +package com.example.homeutils.biz.wechatai.adb; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +/** + * ADB 命令封装 —— 纯 Java 直连安卓设备,无 Python 依赖。 + *

+ * 所有命令通过 {@link ProcessBuilder} 执行,输出按 UTF-8 读取; + * 二进制输出(截图)使用 {@code exec-out} 避免 Windows 下 CRLF 污染。 + */ +public class AdbClient { + + private static final Logger log = LoggerFactory.getLogger(AdbClient.class); + private static final Duration COMMAND_TIMEOUT = Duration.ofSeconds(30); + + private final String adbPath; + + public AdbClient(String adbPath) { + this.adbPath = adbPath == null || adbPath.isBlank() ? findDefaultAdb() : adbPath; + } + + // ==================== 设备发现 ==================== + + /** + * 查找 adb 可执行文件路径: + * 1) 环境变量 ANDROID_HOME/platform-tools/adb + * 2) 系统 PATH 中的 adb + * 3) 常见默认路径 + */ + public static String findDefaultAdb() { + String androidHome = System.getenv("ANDROID_HOME"); + if (androidHome != null && !androidHome.isBlank()) { + Path candidate = Paths.get(androidHome, "platform-tools", adbExeName()); + if (Files.isRegularFile(candidate)) { + return candidate.toString(); + } + } + Path inPath = findAdbInPath(); + if (inPath != null) { + return inPath.toString(); + } + for (String guess : List.of( + "C:\\platform-tools\\adb.exe", + "C:\\Android\\platform-tools\\adb.exe", + "C:\\adb\\adb.exe", + "/usr/local/bin/adb", + "/usr/bin/adb")) { + if (Files.isRegularFile(Paths.get(guess))) { + return guess; + } + } + // 退化为直接使用 PATH 中的 adb,由系统解析 + return "adb"; + } + + private static String adbExeName() { + return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win") ? "adb.exe" : "adb"; + } + + private static Path findAdbInPath() { + String pathEnv = System.getenv("PATH"); + if (pathEnv == null || pathEnv.isBlank()) { + return null; + } + String sep = System.getProperty("path.separator", ";"); + for (String dir : pathEnv.split(sep)) { + if (dir.isBlank()) { + continue; + } + Path candidate = Paths.get(dir, adbExeName()); + if (Files.isRegularFile(candidate)) { + return candidate; + } + } + return null; + } + + /** 枚举已连接的设备(adb devices -l) */ + public List listDevices() throws IOException { + String output = run(List.of(adbPath, "devices", "-l")); + List devices = new ArrayList<>(); + String[] lines = output.split("\\r?\\n"); + for (int i = 1; i < lines.length; i++) { + String line = lines[i].trim(); + if (line.isEmpty()) { + continue; + } + String[] parts = line.split("\\s+"); + if (parts.length < 2) { + continue; + } + String serial = parts[0]; + String state = parts[1]; + String model = ""; + for (int j = 2; j < parts.length; j++) { + if (parts[j].startsWith("model:")) { + model = parts[j].substring("model:".length()).replace('_', ' ').trim(); + } + } + devices.add(new AdbDevice(serial, state, model)); + } + return devices; + } + + /** 连接无线设备(adb connect host:port),返回 null 表示成功,否则返回错误信息 */ + public String connect(String hostPort) throws IOException { + String output = run(List.of(adbPath, "connect", hostPort.trim())); + String lower = output.toLowerCase(Locale.ROOT); + if (lower.contains("connected to")) { + return null; + } + return output.trim(); + } + + /** 断开指定设备 */ + public void disconnect(String serial) throws IOException { + run(List.of(adbPath, "disconnect", serial)); + } + + // ==================== 应用与前台 ==================== + + /** 获取前台 Activity 所在包名(dumpsys window),未找到返回空串 */ + public String foregroundPackage(String serial) throws IOException { + String output = shell(serial, "dumpsys", "window"); + // 逐行查找 mCurrentFocus / mFocusedApp,取包名 + for (String line : output.split("\\r?\\n")) { + String t = line.trim(); + if ((t.startsWith("mCurrentFocus") || t.startsWith("mFocusedApp")) && t.contains("u0/")) { + int idx = t.indexOf("u0/"); + String pkg = t.substring(idx + 3); + int slash = pkg.indexOf('/'); + if (slash > 0) { + pkg = pkg.substring(0, slash); + } + pkg = pkg.trim(); + if (!pkg.isEmpty() && !pkg.equals("null")) { + return pkg; + } + } + } + return ""; + } + + /** 启动应用(am start -n),成功返回 true */ + public boolean startApp(String serial, String packageName) throws IOException { + String output = shell(serial, "am", "start", "-n", packageName); + return output.contains("Starting") || !output.toLowerCase(Locale.ROOT).contains("error"); + } + + /** 启动应用(按包名启动默认启动 Activity) */ + public boolean startAppByPackage(String serial, String packageName) throws IOException { + String output = shell(serial, "monkey", "-p", packageName, "-c", "android.intent.category.LAUNCHER", "1"); + return !output.toLowerCase(Locale.ROOT).contains("error"); + } + + /** 判断指定包名是否在前台 */ + public boolean isForeground(String serial, String packageName) throws IOException { + return packageName.equals(foregroundPackage(serial)); + } + + // ==================== 截图 ==================== + + /** + * 截图并保存到本地文件(exec-out screencap -p,避免 CRLF 污染)。 + * + * @return 截图文件路径 + */ + public Path captureScreenshot(String serial, Path target) throws IOException { + Files.createDirectories(target.toAbsolutePath().getParent()); + byte[] png = execOut(serial, "screencap", "-p"); + Files.write(target, png); + return target; + } + + // ==================== UI dump ==================== + + /** + * 抓取当前界面 UI 层级并解析为节点列表。 + *

+ * 优先使用 uiautomator dump;若设备不支持(dump 无输出), + * 回退到 /sdcard/window_dump.xml 或抛异常。 + */ + public List dumpUiNodes(String serial) throws IOException { + shell(serial, "uiautomator", "dump", "/sdcard/window_dump.xml"); + String xml = shell(serial, "cat", "/sdcard/window_dump.xml"); + if (xml == null || xml.isBlank()) { + throw new IOException("UI dump 输出为空,请确认设备已解锁且屏幕点亮"); + } + return parseUiNodes(xml); + } + + /** 解析 uiautomator dump 的 XML,提取节点文本与坐标 */ + public static List parseUiNodes(String xml) throws IOException { + List nodes = new ArrayList<>(); + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document doc = factory.newDocumentBuilder() + .parse(new InputSource(new StringReader(stripBom(xml)))); + NodeList all = doc.getElementsByTagName("*"); + for (int i = 0; i < all.getLength(); i++) { + Element el = (Element) all.item(i); + String cls = el.getTagName(); + String text = el.getAttribute("text"); + String desc = el.getAttribute("content-desc"); + String bounds = el.getAttribute("bounds"); + String clickable = el.getAttribute("clickable"); + String resourceId = el.getAttribute("resource-id"); + if (text.isEmpty() && desc.isEmpty()) { + continue; + } + int[] box = parseBounds(bounds); + if (box == null) { + continue; + } + nodes.add(new UiNode( + text, desc, cls, resourceId, + box[0], box[1], box[2], box[3], + "true".equalsIgnoreCase(clickable))); + } + } catch (Exception e) { + throw new IOException("解析 UI dump XML 失败: " + e.getMessage(), e); + } + return nodes; + } + + private static String stripBom(String xml) { + return xml != null && !xml.isEmpty() && xml.charAt(0) == '\uFEFF' ? xml.substring(1) : xml; + } + + /** 解析 "bounds=[l,t][r,b]" 格式为 [l,t,r,b],失败返回 null */ + private static int[] parseBounds(String bounds) { + if (bounds == null || bounds.isBlank()) { + return null; + } + try { + String s = bounds.trim(); + int a = s.indexOf('['); + int b = s.indexOf(']'); + int c = s.indexOf('[', b); + int d = s.indexOf(']', c); + String[] lt = s.substring(a + 1, b).split(","); + String[] rb = s.substring(c + 1, d).split(","); + return new int[]{ + Integer.parseInt(lt[0].trim()), + Integer.parseInt(lt[1].trim()), + Integer.parseInt(rb[0].trim()), + Integer.parseInt(rb[1].trim())}; + } catch (Exception e) { + return null; + } + } + + // ==================== 模拟输入 ==================== + + public void tap(String serial, int x, int y) throws IOException { + shell(serial, "input", "tap", String.valueOf(x), String.valueOf(y)); + } + + public void swipe(String serial, int x1, int y1, int x2, int y2, int ms) throws IOException { + shell(serial, "input", "swipe", String.valueOf(x1), String.valueOf(y1), + String.valueOf(x2), String.valueOf(y2), String.valueOf(ms)); + } + + /** + * 输入文本(input text 仅支持 ASCII)。 + *

+ * 中文/长文本优先走 ADBKeyboard 广播(需设备已安装 ADBKeyboard 并切换输入法); + * 否则抛出带指引的异常。 + */ + public void inputText(String serial, String text) throws IOException { + if (text == null || text.isEmpty()) { + return; + } + if (isAscii(text)) { + shell(serial, "input", "text", escapeInputText(text)); + return; + } + // 尝试 ADBKeyboard 广播方式输入中文 + String out = shell(serial, "am", "broadcast", "-a", "ADB_INPUT_TEXT", "--es", "msg", text); + if (out.toLowerCase(Locale.ROOT).contains("result=0") || out.toLowerCase(Locale.ROOT).contains("broadcast completed")) { + return; + } + throw new IOException("设备未安装 ADBKeyboard 输入法,无法输入中文文本。" + + "请安装 ADBKeyboard.apk 并切换为该输入法,或将文本改为 ASCII。"); + } + + private static boolean isAscii(String text) { + for (int i = 0; i < text.length(); i++) { + if (text.charAt(i) > 127) { + return false; + } + } + return true; + } + + /** input text 需要对空格与特殊字符转义 */ + private static String escapeInputText(String text) { + return text.replace(" ", "%s") + .replace("&", "\\&") + .replace("<", "\\<") + .replace(">", "\\>") + .replace("|", "\\|") + .replace("\"", "\\\"") + .replace("'", "\\'") + .replace("(", "\\(") + .replace(")", "\\)"); + } + + // ==================== 命令执行 ==================== + + /** 执行 adb shell 命令,返回 stdout 文本(UTF-8) */ + public String shell(String serial, String... args) throws IOException { + List cmd = new ArrayList<>(); + cmd.add(adbPath); + cmd.add("-s"); + cmd.add(serial); + cmd.add("shell"); + cmd.addAll(List.of(args)); + return run(cmd); + } + + /** 执行 adb exec-out 命令,返回原始字节 */ + public byte[] execOut(String serial, String... args) throws IOException { + List cmd = new ArrayList<>(); + cmd.add(adbPath); + cmd.add("-s"); + cmd.add(serial); + cmd.add("exec-out"); + cmd.addAll(List.of(args)); + return runBytes(cmd); + } + + private String run(List cmd) throws IOException { + byte[] bytes = runBytes(cmd); + return new String(bytes, StandardCharsets.UTF_8); + } + + private byte[] runBytes(List cmd) throws IOException { + log.debug("执行 adb 命令: {}", String.join(" ", cmd)); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(false); + Process proc; + try { + proc = pb.start(); + } catch (IOException e) { + throw new IOException("无法启动 adb 进程(路径: " + adbPath + ")," + + "请确认 adb 可用并在设置中配置正确的 adb 路径。", e); + } + try (InputStream out = proc.getInputStream(); InputStream err = proc.getErrorStream()) { + ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + Thread readerOut = pump(out, stdout); + Thread readerErr = pump(err, stderr); + boolean finished = proc.waitFor(COMMAND_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + if (!finished) { + proc.destroyForcibly(); + throw new IOException("adb 命令超时(" + COMMAND_TIMEOUT.getSeconds() + "s): " + String.join(" ", cmd)); + } + readerOut.join(2000); + readerErr.join(2000); + if (proc.exitValue() != 0) { + String errText = stderr.toString(StandardCharsets.UTF_8).trim(); + if (!errText.isEmpty()) { + throw new IOException("adb 命令失败(" + proc.exitValue() + "): " + errText); + } + } + return stdout.toByteArray(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("adb 命令被中断", e); + } + } + + private static Thread pump(InputStream in, ByteArrayOutputStream sink) { + Thread t = new Thread(() -> { + byte[] buf = new byte[8192]; + int n; + try { + while ((n = in.read(buf)) != -1) { + sink.write(buf, 0, n); + } + } catch (IOException ignored) { + // 进程结束后的读取中断可忽略 + } + }, "adb-stdout-pump"); + t.setDaemon(true); + t.start(); + return t; + } + + /** UI 节点模型(bounds 为 [l,t,r,b]) */ + public record UiNode(String text, String contentDesc, String className, String resourceId, + int l, int t, int r, int b, boolean clickable) { + + public int centerX() { + return (l + r) / 2; + } + + public int centerY() { + return (t + b) / 2; + } + + public boolean contains(int x, int y) { + return x >= l && x <= r && y >= t && y <= b; + } + + public String displayText() { + return text != null && !text.isBlank() ? text : contentDesc; + } + + @Override + public String toString() { + return displayText() + " @(" + l + "," + t + ")-(" + r + "," + b + ")"; + } + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbDevice.java b/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbDevice.java new file mode 100644 index 0000000..99fbe94 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/adb/AdbDevice.java @@ -0,0 +1,27 @@ +package com.example.homeutils.biz.wechatai.adb; + +/** + * ADB 设备模型。 + * + * @param serial 设备序列号(USB 为 `device://` 形式,无线为 `host:port`) + * @param state 设备状态(device / offline / unauthorized / no permissions) + * @param model 设备型号(来自 `adb devices -l` 的 model 字段,可能为空) + */ +public record AdbDevice(String serial, String state, String model) { + + /** 是否为可操作状态(device) */ + public boolean isOnline() { + return "device".equalsIgnoreCase(state); + } + + /** 是否为无线连接(serial 包含 host:port 形式) */ + public boolean isWireless() { + return serial.contains(":") && !serial.startsWith("device:"); + } + + @Override + public String toString() { + String display = model == null || model.isBlank() ? serial : model + " (" + serial + ")"; + return display + " [" + state + "]"; + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/ai/AiConfig.java b/src/main/java/com/example/homeutils/biz/wechatai/ai/AiConfig.java new file mode 100644 index 0000000..ee11b98 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/ai/AiConfig.java @@ -0,0 +1,31 @@ +package com.example.homeutils.biz.wechatai.ai; + +/** + * AI 模型配置(OpenAI 兼容接口)。 + */ +public record AiConfig( + String baseUrl, + String apiKey, + String model, + double temperature, + int timeoutSeconds, + boolean supportsVision) { + + public static AiConfig defaults() { + return new AiConfig("https://api.deepseek.com/v1", "", "deepseek-chat", 0.7, 30, false); + } + + /** 拼接 chat/completions 端点(处理 baseUrl 尾部斜杠) */ + public String chatEndpoint() { + String base = baseUrl == null ? "" : baseUrl.trim(); + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base + "/chat/completions"; + } + + public boolean isValid() { + return baseUrl != null && !baseUrl.isBlank() && apiKey != null && !apiKey.isBlank() + && model != null && !model.isBlank(); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/ai/AiResponder.java b/src/main/java/com/example/homeutils/biz/wechatai/ai/AiResponder.java new file mode 100644 index 0000000..46c2d64 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/ai/AiResponder.java @@ -0,0 +1,259 @@ +package com.example.homeutils.biz.wechatai.ai; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.example.homeutils.biz.wechatai.wechat.WechatMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Base64; +import java.util.List; + +/** + * AI 回复器 —— 固定模板命中优先,未命中则调用 OpenAI 兼容 chat/completions。 + *

+ * 使用 JDK17 内置 HttpClient,不引入额外依赖;超时 30s + 单次重试。 + */ +public class AiResponder { + + private static final Logger log = LoggerFactory.getLogger(AiResponder.class); + + /** AI 生成结果 */ + public record AiReply(String text, String source) { + public static AiReply template(String text) { + return new AiReply(text, "固定模板"); + } + + public static AiReply generated(String text) { + return new AiReply(text, "AI生成"); + } + } + + private final HttpClient http; + + public AiResponder() { + this.http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .version(HttpClient.Version.HTTP_1_1) + .build(); + } + + /** + * 生成回复。 + * + * @param config AI 配置(baseUrl/apiKey/model) + * @param persona 人设描述(system prompt),可为空 + * @param templates 固定模板列表,命中优先 + * @param newMessages 需要回复的新消息 + * @param history 最近的聊天历史(转述上下文),可为空 + * @param screenshot 可选截图路径(消息含图片且模型支持视觉时附带) + */ + public AiReply generateReply(AiConfig config, String persona, List templates, + List newMessages, List history, + Path screenshot) throws IOException { + // 1) 固定模板优先 + for (WechatMessage msg : newMessages) { + if (msg.content() == null) { + continue; + } + for (ReplyTemplate t : templates) { + if (t.matches(msg.content())) { + log.info("命中固定模板 [{}] -> {}", t.keyword(), t.reply()); + return AiReply.template(t.reply()); + } + } + } + + // 2) 校验配置 + if (!config.isValid()) { + throw new IOException("AI 配置不完整:请填写 base-url / api-key / model"); + } + + // 3) 组装 messages + JSONObject body = buildRequestBody(config, persona, newMessages, history, screenshot); + String payload = body.toJSONString(); + + // 4) HTTP 调用(超时 30s,重试 1 次) + IOException last = null; + for (int attempt = 0; attempt < 2; attempt++) { + try { + String json = postJson(config, payload); + return AiReply.generated(parseReplyText(json)); + } catch (IOException e) { + last = e; + log.warn("AI 请求失败(第{}次): {}", attempt + 1, e.getMessage()); + if (attempt == 0) { + try { + Thread.sleep(500); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + break; + } + } + } + } + throw new IOException("AI 服务调用失败: " + (last == null ? "未知错误" : last.getMessage()), last); + } + + private JSONObject buildRequestBody(AiConfig config, String persona, + List newMessages, List history, + Path screenshot) { + JSONArray messages = new JSONArray(); + + // system:人设 + 规则 + StringBuilder sys = new StringBuilder(); + if (persona != null && !persona.isBlank()) { + sys.append(persona).append("\n\n"); + } + sys.append("你现在在替代用户通过微信与联系人对话。规则:"); + sys.append("1. 语气自然口语化,像真人微信聊天;"); + sys.append("2. 回复要简短(一般不超过60字),避免长篇大论;"); + sys.append("3. 除非用户人设明确要求,否则不要自称是AI或机器人;"); + sys.append("4. 只回复最新一条消息,不要重复对方的话。"); + messages.add(content("system", sys.toString())); + + // 历史上下文(最近 8 条,仅文本) + if (history != null && !history.isEmpty()) { + StringBuilder hist = new StringBuilder("最近聊天记录(按时间先后):\n"); + int start = Math.max(0, history.size() - 8); + for (int i = start; i < history.size(); i++) { + WechatMessage m = history.get(i); + hist.append(m.sender() == null || m.sender().isBlank() ? "对方" : m.sender()) + .append(": ") + .append(m.content() == null ? "" : m.content()) + .append("\n"); + } + messages.add(content("system", hist.toString())); + } + + // user:需要回复的新消息(最多3条,控制上下文) + StringBuilder user = new StringBuilder(); + int from = Math.max(0, newMessages.size() - 3); + for (int i = from; i < newMessages.size(); i++) { + WechatMessage m = newMessages.get(i); + String body = m.content() == null ? "" : m.content(); + if (m.type() == WechatMessage.Type.IMAGE) { + body = "[图片消息]"; + } + user.append("对方: ").append(body).append("\n"); + } + user.append("请生成回复:"); + + // 视觉支持:附带截图(仅当消息含图片且开启视觉) + if (screenshot != null && Files.isRegularFile(screenshot) + && newMessages.stream().anyMatch(m -> m.type() == WechatMessage.Type.IMAGE) + && config.supportsVision()) { + try { + String b64 = Base64.getEncoder().encodeToString(Files.readAllBytes(screenshot)); + JSONArray contentArr = new JSONArray(); + contentArr.add(imageBlock(b64)); + contentArr.add(textBlock(user.toString())); + JSONObject obj = new JSONObject(); + obj.put("type", "user"); + obj.put("content", contentArr); + messages.add(obj); + } catch (IOException e) { + log.warn("读取截图失败,降级为纯文本请求: {}", e.getMessage()); + messages.add(content("user", user.toString())); + } + } else { + messages.add(content("user", user.toString())); + } + + JSONObject body = new JSONObject(); + body.put("model", config.model()); + body.put("messages", messages); + body.put("temperature", config.temperature()); + body.put("stream", false); + return body; + } + + private JSONObject content(String role, String text) { + JSONObject obj = new JSONObject(); + obj.put("role", role); + obj.put("content", text); + return obj; + } + + private JSONObject textBlock(String text) { + JSONObject obj = new JSONObject(); + obj.put("type", "text"); + obj.put("text", text); + return obj; + } + + private JSONObject imageBlock(String base64) { + JSONObject obj = new JSONObject(); + obj.put("type", "image_url"); + JSONObject url = new JSONObject(); + url.put("url", "data:image/png;base64," + base64); + obj.put("image_url", url); + return obj; + } + + private String postJson(AiConfig config, String payload) throws IOException { + HttpRequest request; + try { + request = HttpRequest.newBuilder() + .uri(URI.create(config.chatEndpoint())) + .timeout(Duration.ofSeconds(Math.max(5, config.timeoutSeconds()))) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + config.apiKey()) + .POST(HttpRequest.BodyPublishers.ofString(payload, StandardCharsets.UTF_8)) + .build(); + } catch (IllegalArgumentException e) { + throw new IOException("AI 接口地址无效: " + config.baseUrl(), e); + } + HttpResponse response; + try { + response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("AI 请求被中断", e); + } + if (response.statusCode() != 200) { + String detail = truncate(response.body(), 200); + throw new IOException("AI 接口返回 HTTP " + response.statusCode() + ": " + detail); + } + return response.body(); + } + + /** 解析 chat/completions 响应中的回复文本 */ + private String parseReplyText(String json) throws IOException { + try { + JSONObject root = JSON.parseObject(json); + JSONArray choices = root.getJSONArray("choices"); + if (choices == null || choices.isEmpty()) { + throw new IOException("AI 响应缺少 choices"); + } + JSONObject message = choices.getJSONObject(0).getJSONObject("message"); + String text = message == null ? null : message.getString("content"); + if (text == null || text.isBlank()) { + throw new IOException("AI 响应内容为空"); + } + return text.trim(); + } catch (Exception e) { + if (e instanceof IOException io) { + throw io; + } + throw new IOException("解析 AI 响应失败: " + e.getMessage() + " | 响应: " + truncate(json, 150), e); + } + } + + private static String truncate(String s, int max) { + if (s == null) { + return ""; + } + return s.length() <= max ? s : s.substring(0, max) + "…"; + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/ai/ReplyTemplate.java b/src/main/java/com/example/homeutils/biz/wechatai/ai/ReplyTemplate.java new file mode 100644 index 0000000..f698bad --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/ai/ReplyTemplate.java @@ -0,0 +1,11 @@ +package com.example.homeutils.biz.wechatai.ai; + +/** + * 固定回复模板:关键词命中优先于 AI 生成,保证确定性场景可控。 + */ +public record ReplyTemplate(String keyword, String reply) { + + public boolean matches(String text) { + return text != null && !keyword.isBlank() && text.contains(keyword.trim()); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/config/WechatAiProperties.java b/src/main/java/com/example/homeutils/biz/wechatai/config/WechatAiProperties.java new file mode 100644 index 0000000..1f7d169 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/config/WechatAiProperties.java @@ -0,0 +1,194 @@ +package com.example.homeutils.biz.wechatai.config; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.example.homeutils.biz.wechatai.ai.AiConfig; +import com.example.homeutils.biz.wechatai.ai.ReplyTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +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; + +/** + * 微信 AI 助手配置 —— 持久化到 data/wechat-ai/config.json。 + *

+ * 线程安全:读写均通过 synchronized,UI 线程调用安全。 + */ +public class WechatAiProperties { + + private static final Logger log = LoggerFactory.getLogger(WechatAiProperties.class); + + private final Path file; + + // 运行态字段 + private volatile List contacts = new ArrayList<>(); + private volatile String persona = ""; + private volatile List templates = new ArrayList<>(); + private volatile AiConfig aiConfig = AiConfig.defaults(); + private volatile long monitorIntervalMs = 3000; + private volatile boolean autoReplyEnabled = false; + private volatile String adbPath = ""; + + public WechatAiProperties(Path file) { + this.file = file; + } + + public static WechatAiProperties defaults() { + return new WechatAiProperties(Paths.get("data", "wechat-ai", "config.json")); + } + + public synchronized void load() { + if (!Files.isRegularFile(file)) { + log.info("配置文件不存在,使用默认配置: {}", file); + save(); + return; + } + try { + String text = Files.readString(file, StandardCharsets.UTF_8); + JSONObject root = JSON.parseObject(text); + if (root == null) { + return; + } + contacts = new ArrayList<>(root.getJSONArray("contacts").toJavaList(String.class)); + persona = root.getString("persona") == null ? "" : root.getString("persona"); + List loaded = new ArrayList<>(); + JSONObject templatesObj = root.getJSONObject("templates"); + if (templatesObj != null) { + JSONArray raw = templatesObj.getJSONArray("rules"); + if (raw != null) { + for (int i = 0; i < raw.size(); i++) { + JSONObject r = raw.getJSONObject(i); + loaded.add(new ReplyTemplate( + r.getString("keyword") == null ? "" : r.getString("keyword"), + r.getString("reply") == null ? "" : r.getString("reply"))); + } + } + } + templates = loaded; + aiConfig = new AiConfig( + root.getString("baseUrl") == null ? AiConfig.defaults().baseUrl() : root.getString("baseUrl"), + root.getString("apiKey") == null ? "" : root.getString("apiKey"), + root.getString("model") == null ? AiConfig.defaults().model() : root.getString("model"), + root.getDoubleValue("temperature") == 0 ? 0.7 : root.getDoubleValue("temperature"), + root.getIntValue("timeoutSeconds") <= 0 ? 30 : root.getIntValue("timeoutSeconds"), + root.getBooleanValue("supportsVision")); + long interval = root.getLongValue("monitorIntervalMs"); + monitorIntervalMs = Math.max(1000, interval <= 0 ? 3000 : interval); + autoReplyEnabled = root.getBooleanValue("autoReplyEnabled"); + adbPath = root.getString("adbPath") == null ? "" : root.getString("adbPath"); + log.info("配置已加载: {} (联系人 {} 个, 模板 {} 条)", file, contacts.size(), templates.size()); + } catch (Exception e) { + log.warn("加载配置失败,使用默认值: {}", e.getMessage()); + } + } + + public synchronized void save() { + try { + Files.createDirectories(file.toAbsolutePath().getParent()); + JSONObject root = new JSONObject(); + root.put("contacts", contacts); + root.put("persona", persona); + JSONObject templatesObj = new JSONObject(); + JSONArray rules = new JSONArray(); + for (ReplyTemplate t : templates) { + JSONObject r = new JSONObject(); + r.put("keyword", t.keyword()); + r.put("reply", t.reply()); + rules.add(r); + } + templatesObj.put("rules", rules); + root.put("templates", templatesObj); + root.put("baseUrl", aiConfig.baseUrl()); + root.put("apiKey", aiConfig.apiKey()); + root.put("model", aiConfig.model()); + root.put("temperature", aiConfig.temperature()); + root.put("timeoutSeconds", aiConfig.timeoutSeconds()); + root.put("supportsVision", aiConfig.supportsVision()); + root.put("monitorIntervalMs", monitorIntervalMs); + root.put("autoReplyEnabled", autoReplyEnabled); + root.put("adbPath", adbPath); + Files.writeString(file, JSON.toJSONString(root, SerializerFeature.PrettyFormat), StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("保存配置失败: {}", e.getMessage()); + } + } + + // ==================== getters ==================== + + public synchronized List getContacts() { + return new ArrayList<>(contacts); + } + + public synchronized String getPersona() { + return persona; + } + + public synchronized List getTemplates() { + return new ArrayList<>(templates); + } + + public synchronized AiConfig getAiConfig() { + return aiConfig; + } + + public synchronized long getMonitorIntervalMs() { + return monitorIntervalMs; + } + + public synchronized boolean isAutoReplyEnabled() { + return autoReplyEnabled; + } + + public synchronized String getAdbPath() { + return adbPath; + } + + public Path configFile() { + return file; + } + + // ==================== setters(修改后立即持久化) ==================== + + public synchronized void setContacts(List contacts) { + this.contacts = contacts == null ? new ArrayList<>() : new ArrayList<>(contacts); + save(); + } + + public synchronized void setPersona(String persona) { + this.persona = persona == null ? "" : persona; + save(); + } + + public synchronized void setTemplates(List templates) { + this.templates = templates == null ? new ArrayList<>() : new ArrayList<>(templates); + save(); + } + + public synchronized void setAiConfig(AiConfig config) { + this.aiConfig = config; + save(); + } + + public synchronized void setMonitorIntervalMs(long monitorIntervalMs) { + this.monitorIntervalMs = Math.max(1000, monitorIntervalMs); + save(); + } + + public synchronized void setAutoReplyEnabled(boolean enabled) { + this.autoReplyEnabled = enabled; + save(); + } + + public synchronized void setAdbPath(String adbPath) { + this.adbPath = adbPath == null ? "" : adbPath; + save(); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/wechat/MessageMonitor.java b/src/main/java/com/example/homeutils/biz/wechatai/wechat/MessageMonitor.java new file mode 100644 index 0000000..e3239b0 --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/wechat/MessageMonitor.java @@ -0,0 +1,231 @@ +package com.example.homeutils.biz.wechatai.wechat; + +import com.example.homeutils.biz.wechatai.adb.AdbClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 指定联系人消息监测器 —— 定时轮询目标聊天页,与上次快照比对,发现新消息后回调事件。 + *

+ * 线程模型:单线程调度,轮询间隔可配(默认 3 秒)。所有回调在调度线程执行, + * 监听器内耗时操作应自行异步(UI 侧用 Platform.runLater)。 + */ +public class MessageMonitor { + + private static final Logger log = LoggerFactory.getLogger(MessageMonitor.class); + + /** 监测事件回调 */ + public interface MessageMonitorListener { + /** 检测到新消息(fresh 为新增消息列表,screenshot 可能为空) */ + void onNewMessages(WechatSession session, List fresh, Path screenshot); + + /** 监测过程出错(设备断开/解析失败等),message 为可读错误 */ + void onMonitorError(String message); + + /** 运行状态变化 */ + void onStatusChanged(boolean running); + } + + private final AdbClient adb; + private final WechatSessionReader reader; + private final AtomicBoolean running = new AtomicBoolean(false); + private volatile ScheduledExecutorService executor; + + private volatile String serial; + private volatile String contactName; + private volatile long intervalMs = 3000; + + private volatile MessageMonitorListener listener; + + // 快照基线 + private volatile String lastHash = ""; + private volatile int lastCount = -1; + private volatile String lastTitle = ""; + + public MessageMonitor(AdbClient adb, WechatSessionReader reader) { + this.adb = adb; + this.reader = reader; + } + + public void setListener(MessageMonitorListener listener) { + this.listener = listener; + } + + public void setTarget(String serial, String contactName) { + this.serial = serial; + this.contactName = contactName; + } + + public void setInterval(long intervalMs) { + this.intervalMs = Math.max(1000, intervalMs); + // 运行中动态调整周期 + if (running.get()) { + restart(); + } + } + + public boolean isRunning() { + return running.get(); + } + + /** 启动监测(若已在运行则先停止) */ + public synchronized void start() { + if (running.get()) { + return; + } + if (serial == null || serial.isBlank() || contactName == null || contactName.isBlank()) { + notifyError("监测未启动:请先选择设备并指定联系人"); + return; + } + resetBaselineInternal(); + executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "wechat-monitor"); + t.setDaemon(true); + return t; + }); + running.set(true); + notifyStatus(true); + executor.scheduleWithFixedDelay(this::pollOnce, 0, intervalMs, TimeUnit.MILLISECONDS); + log.info("消息监测已启动: serial={}, contact={}, interval={}ms", serial, contactName, intervalMs); + } + + /** 停止监测 */ + public synchronized void stop() { + if (!running.get()) { + return; + } + running.set(false); + if (executor != null) { + executor.shutdownNow(); + executor = null; + } + notifyStatus(false); + log.info("消息监测已停止"); + } + + private void restart() { + synchronized (this) { + if (!running.get()) { + return; + } + stop(); + start(); + } + } + + /** 发送回复后调用,将基线重置为当前界面最后消息,避免把"自己发出的消息"当作新消息 */ + public void resetBaseline() { + resetBaselineInternal(); + log.info("已重置消息监测基线"); + } + + private void resetBaselineInternal() { + this.lastHash = ""; + this.lastCount = -1; + this.lastTitle = ""; + } + + /** 单次轮询:进入目标聊天 → dump → 比对 → 回调 */ + private void pollOnce() { + String curSerial = serial; + String curContact = contactName; + if (curSerial == null || curContact == null || curContact.isBlank()) { + return; + } + try { + // 1) 确保微信在前台 + if (!reader.isWechatForeground(curSerial)) { + if (!reader.launchWechat(curSerial)) { + throw new IOException("无法启动微信"); + } + Thread.sleep(1500); + } + // 2) 确保在目标聊天页 + WechatSession preview = reader.readCurrentChat(curSerial); + if (!preview.title().equals(curContact)) { + // 当前不在目标聊天,尝试从会话列表进入 + if (!reader.openChat(curSerial, curContact)) { + // 找不到,可能是聊天在后台未打开——尝试先回主页再找 + if (reader.isWechatForeground(curSerial) && !preview.title().isBlank() && !"微信".equals(preview.title())) { + // 停留在其他聊天页,无法自动切换,提示用户 + notifyError("当前停留在其他聊天(" + preview.title() + "),无法自动进入【" + curContact + "】的会话,请手动切换"); + return; + } + notifyError("未找到联系人【" + curContact + "】的会话入口,请确认联系人名称正确"); + return; + } + Thread.sleep(1200); + preview = reader.readCurrentChat(curSerial); + if (!preview.title().equals(curContact)) { + notifyError("进入【" + curContact + "】的聊天失败,请手动打开该聊天"); + return; + } + } + // 3) 快照比对 + WechatSession session = preview; + String currentHash = session.lastHash(); + int currentCount = session.messages().size(); + if (currentHash.isEmpty()) { + // 界面为空或解析失败,记录基线但不触发 + lastHash = ""; + lastCount = currentCount; + lastTitle = session.title(); + return; + } + boolean countChanged = lastCount >= 0 && currentCount > lastCount; + boolean hashChanged = !currentHash.equals(lastHash); + if (hashChanged && (countChanged || !lastTitle.equals(session.title()))) { + // 判定为有新消息:取末尾新增部分 + List fresh; + if (lastCount >= 0 && currentCount > lastCount && currentCount - lastCount <= 10) { + fresh = session.messages().subList(lastCount, currentCount); + } else { + fresh = List.of(session.lastMessage()); + } + Path shot = null; + try { + shot = reader.captureScreen(curSerial, Path.of("data", "wechat-ai", "screenshots")); + } catch (IOException e) { + log.warn("监测截图失败(不影响监测): {}", e.getMessage()); + } + lastHash = currentHash; + lastCount = currentCount; + lastTitle = session.title(); + if (listener != null) { + listener.onNewMessages(session, fresh, shot); + } + return; + } + lastHash = currentHash; + lastCount = currentCount; + lastTitle = session.title(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.warn("监测轮询异常: {}", e.getMessage()); + notifyError("监测出错: " + e.getMessage()); + } + } + + private void notifyError(String message) { + log.warn(message); + if (listener != null) { + listener.onMonitorError(message); + } + } + + private void notifyStatus(boolean running) { + if (listener != null) { + listener.onStatusChanged(running); + } + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatMessage.java b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatMessage.java new file mode 100644 index 0000000..496662f --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatMessage.java @@ -0,0 +1,29 @@ +package com.example.homeutils.biz.wechatai.wechat; + +/** + * 微信消息模型。 + */ +public record WechatMessage(Type type, String sender, String content, String time) { + + public enum Type { + TEXT, // 文本消息 + IMAGE, // 图片消息(content 为占位描述) + VOICE, // 语音消息 + UNKNOWN // 其他/解析失败 + } + + /** 消息摘要(用于快照比对与日志展示) */ + public String digest() { + String body = content == null ? "" : content; + if (body.length() > 40) { + body = body.substring(0, 40) + "…"; + } + return type + "|" + body + "|" + time; + } + + @Override + public String toString() { + String who = sender == null || sender.isBlank() ? "" : sender + ": "; + return (time == null ? "" : time + " ") + who + (content == null ? "" : content); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSession.java b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSession.java new file mode 100644 index 0000000..ecd086a --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSession.java @@ -0,0 +1,20 @@ +package com.example.homeutils.biz.wechatai.wechat; + +import java.util.List; + +/** + * 微信会话(聊天记录)模型。 + */ +public record WechatSession(String title, List messages) { + + /** 最后一条消息 */ + public WechatMessage lastMessage() { + return messages == null || messages.isEmpty() ? null : messages.get(messages.size() - 1); + } + + /** 最后一条消息的指纹(用于快照比对检测新消息) */ + public String lastHash() { + WechatMessage last = lastMessage(); + return last == null ? "" : last.digest(); + } +} diff --git a/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSessionReader.java b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSessionReader.java new file mode 100644 index 0000000..59395ea --- /dev/null +++ b/src/main/java/com/example/homeutils/biz/wechatai/wechat/WechatSessionReader.java @@ -0,0 +1,279 @@ +package com.example.homeutils.biz.wechatai.wechat; + +import com.example.homeutils.biz.wechatai.adb.AdbClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.Predicate; + +/** + * 微信会话读取器 —— 通过 uiautomator dump 解析聊天记录文本节点。 + *

+ * 策略:dump 优先(精确、免费、零 OCR 误差),截图仅在需要视觉兜底/图片消息时由外部触发。 + */ +public class WechatSessionReader { + + private static final Logger log = LoggerFactory.getLogger(WechatSessionReader.class); + + public static final String WECHAT_PACKAGE = "com.tencent.mm"; + public static final String WECHAT_LAUNCHER = "com.tencent.mm/.ui.LauncherUI"; + + private final AdbClient adb; + + public WechatSessionReader(AdbClient adb) { + this.adb = adb; + } + + /** 启动手机微信 */ + public boolean launchWechat(String serial) throws IOException { + return adb.startApp(serial, WECHAT_LAUNCHER); + } + + /** 微信是否在前台 */ + public boolean isWechatForeground(String serial) throws IOException { + return adb.isForeground(serial, WECHAT_PACKAGE); + } + + /** 截图保存到 targetDir 下名为 wx_时间戳.png 的文件,返回路径 */ + public Path captureScreen(String serial, Path targetDir) throws IOException { + Files.createDirectories(targetDir); + Path out = targetDir.resolve("wx_" + System.currentTimeMillis() + ".png"); + return adb.captureScreenshot(serial, out); + } + + // ==================== 会话列表 ==================== + + /** + * 读取当前界面(会话列表)中的会话标题。 + * 返回非空标题集合,用于 UI 展示与联系人匹配。 + */ + public List readChatList(String serial) throws IOException { + List nodes = adb.dumpUiNodes(serial); + List titles = new ArrayList<>(); + Set ignore = ignoreTexts(); + for (AdbClient.UiNode n : nodes) { + if (!"android.widget.TextView".equals(n.className())) { + continue; + } + String t = n.displayText(); + if (t == null || t.isBlank() || ignore.contains(t) || isTimestamp(t)) { + continue; + } + titles.add(t); + } + return titles; + } + + /** + * 在会话列表中查找并点击指定联系人的会话入口。 + * 命中返回 true;未命中返回 false(UI 可提示手动打开聊天)。 + */ + public boolean openChat(String serial, String contactName) throws IOException { + List nodes = adb.dumpUiNodes(serial); + AdbClient.UiNode target = null; + for (AdbClient.UiNode n : nodes) { + String t = n.displayText(); + if (t != null && t.trim().equals(contactName)) { + target = n; + break; + } + } + if (target == null) { + log.warn("会话列表中未找到联系人: {}", contactName); + return false; + } + adb.tap(serial, target.centerX(), target.centerY()); + return true; + } + + // ==================== 当前聊天页 ==================== + + /** + * 读取当前聊天界面,解析为会话(标题 + 消息序列)。 + * 解析失败抛出 IOException,由调用方决定是否回退截图+视觉。 + */ + public WechatSession readCurrentChat(String serial) throws IOException { + List nodes = adb.dumpUiNodes(serial); + return parseChatPage(nodes); + } + + /** 从 UI 节点解析聊天页:标题 + 消息 */ + public WechatSession parseChatPage(List nodes) { + if (nodes.isEmpty()) { + return new WechatSession("", List.of()); + } + // 推断屏幕尺寸 + int screenH = 1; + int screenW = 1; + for (AdbClient.UiNode n : nodes) { + screenW = Math.max(screenW, n.r()); + screenH = Math.max(screenH, n.b()); + } + + // 1) 标题:顶部居中且非忽略词的文本节点 + String title = resolveTitle(nodes, screenH); + + // 2) 消息节点:TextView 文本,过滤标题/底部输入区/系统栏 + List candidates = new ArrayList<>(); + for (AdbClient.UiNode n : nodes) { + if (!"android.widget.TextView".equals(n.className())) { + continue; + } + String t = n.displayText(); + if (t == null || t.isBlank() || isIgnored(t, screenH, screenW)) { + continue; + } + candidates.add(n); + } + // 按 y 排序(上→下),同 y 按 x + candidates.sort((a, b) -> a.centerY() != b.centerY() + ? Integer.compare(a.centerY(), b.centerY()) + : Integer.compare(a.centerX(), b.centerX())); + + List messages = buildMessages(candidates, screenH); + return new WechatSession(title, messages); + } + + /** 聊天页顶部标题解析:第一个"居中大字"文本 */ + private String resolveTitle(List nodes, int screenH) { + int titleBand = Math.max(120, screenH / 12); + List top = new ArrayList<>(); + for (AdbClient.UiNode n : nodes) { + if (!"android.widget.TextView".equals(n.className())) { + continue; + } + String t = n.displayText(); + if (t == null || t.isBlank() || t.length() > 24 || isTimestamp(t)) { + continue; + } + if (n.centerY() < titleBand) { + top.add(n); + } + } + // 取最靠近屏幕中央的顶部节点作为标题 + top.sort((a, b) -> Integer.compare(distanceToCenterX(a), distanceToCenterX(b))); + return top.isEmpty() ? "" : top.get(0).displayText(); + } + + private static int distanceToCenterX(AdbClient.UiNode n) { + // 无法获得屏宽时用绝对居中估算 + return Math.abs(n.centerX() - 540); + } + + /** 从候选节点构建消息序列(含时间戳识别与群聊昵称识别) */ + private List buildMessages(List candidates, int screenH) { + List messages = new ArrayList<>(); + AdbClient.UiNode prev = null; + for (AdbClient.UiNode n : candidates) { + String t = n.displayText(); + if (isTimestamp(t)) { + messages.add(new WechatMessage(WechatMessage.Type.UNKNOWN, "", t, t)); + prev = n; + continue; + } + // 群聊昵称识别:位于消息气泡上方、以":"结尾或极短且紧邻上一条消息 + String sender = null; + if (prev != null && isSmallLabel(t) && isDirectlyAbove(n, prev)) { + sender = t.replace(":", "").trim(); + prev = n; + continue; + } + WechatMessage.Type type = inferType(n); + messages.add(new WechatMessage(type, sender == null ? "" : sender, t, null)); + prev = n; + } + return messages; + } + + /** 是否为时间戳文本:短文本且含冒号/上午/下午/星期 */ + private static boolean isTimestamp(String text) { + if (text == null || text.length() > 12) { + return false; + } + String s = text.trim(); + return s.matches(".*\\d{1,2}:\\d{2}.*") + || s.matches("(上午|下午|凌晨|中午|晚上).*") + || s.matches("昨天|前天|星期一|星期二|星期三|星期四|星期五|星期六|星期日|今天"); + } + + /** 是否为群聊昵称小字(短文本或带冒号) */ + private static boolean isSmallLabel(String text) { + if (text == null || text.isBlank()) { + return false; + } + return text.length() <= 15 || text.trim().endsWith(":"); + } + + /** n 是否位于 prev 正上方且间距小(同一消息区) */ + private static boolean isDirectlyAbove(AdbClient.UiNode n, AdbClient.UiNode prev) { + return n.centerY() < prev.centerY() + && (prev.centerY() - n.centerY()) < 120 + && Math.abs(n.centerX() - prev.centerX()) < 400; + } + + /** 推断消息类型:图片消息通常 content-desc 非空或文本含占位描述 */ + private static WechatMessage.Type inferType(AdbClient.UiNode n) { + String t = n.displayText(); + if (t != null && (t.contains("[图片]") || t.contains("[照片]"))) { + return WechatMessage.Type.IMAGE; + } + if (t != null && t.startsWith("[") && t.endsWith("]")) { + return WechatMessage.Type.VOICE; + } + return WechatMessage.Type.TEXT; + } + + /** 忽略的关键文本(底部导航/输入区/系统元素) */ + private static Set ignoreTexts() { + return Set.of("微信", "通讯录", "发现", "我", "更多功能", "搜索", + "按住 说话", "语音输入", "发送", "取消", "退出", "关闭", + "照片", "拍摄", "视频通话", "位置", "收藏", "个人名片", "文件"); + } + + /** 是否应忽略该文本节点 */ + private boolean isIgnored(String text, int screenH, int screenW) { + String t = text.trim(); + if (ignoreTexts().contains(t)) { + return true; + } + if (isTimestamp(t)) { + return false; // 时间戳不作为消息内容,但也需要保留处理 + } + // 过长说明可能是整页文本(如文章/公众号内容),保留 + return false; + } + + /** 忽略文本集合(供 readChatList 使用) */ + public Set ignoredTexts() { + return ignoreTexts(); + } + + /** 判断文本是否属于需要忽略的固定文案(供外部使用) */ + public boolean isIgnoreText(String text) { + return text != null && ignoreTexts().contains(text.trim()); + } + + /** 上滑加载更多历史(屏幕下部向上滑动) */ + public void scrollUp(String serial) throws IOException { + List nodes = adb.dumpUiNodes(serial); + int screenH = 1; + for (AdbClient.UiNode n : nodes) { + screenH = Math.max(screenH, n.b()); + } + int h = Math.max(screenH, 800); + adb.swipe(serial, 540, (int) (h * 0.7), 540, (int) (h * 0.25), 400); + } + + /** 判断当前是否处于某个聊天界面(顶部有标题且非"微信"主界面) */ + public boolean isInChat(String serial) throws IOException { + WechatSession session = readCurrentChat(serial); + return session.title() != null && !session.title().isBlank() && !"微信".equals(session.title()); + } +} diff --git a/src/main/java/com/example/homeutils/system/config/ConfigInitializer.java b/src/main/java/com/example/homeutils/system/config/ConfigInitializer.java new file mode 100644 index 0000000..b3f68c7 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/ConfigInitializer.java @@ -0,0 +1,35 @@ +package com.example.homeutils.system.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.stereotype.Component; + +/** + * 配置初始化器 —— Spring 启动完成后加载外部配置 + *

+ * data/ 目录下的配置文件由 HomeUtilsProperties 在 @PostConstruct 阶段统一确保存在 + * (不存在时自动从 classpath:templates/ 复制)。 + * 本初始化器负责加载 homeutils.config 并合并到运行属性中。 + *

+ * 优先级: data/homeutils.config > application.yml > 默认值 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ConfigInitializer implements ApplicationListener { + + private final HomeUtilsProperties properties; + private final ExternalConfigManager configManager; + + @Override + public void onApplicationEvent(ApplicationReadyEvent event) { + if (configManager.exists()) { + log.info("加载外部配置文件: {}", configManager.getConfigFile().toAbsolutePath()); + properties.mergeExternal(configManager.load()); + } else { + log.info("无外部配置文件,使用 application.yml 默认值"); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/config/DatasourceConfig.java b/src/main/java/com/example/homeutils/system/config/DatasourceConfig.java new file mode 100644 index 0000000..ca73be5 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/DatasourceConfig.java @@ -0,0 +1,25 @@ +package com.example.homeutils.system.config; + +import lombok.Data; + +/** + * 单条数据源配置 + */ +@Data +public class DatasourceConfig { + + /** 显示名称(如"开发环境"、"SIT环境") */ + private String name; + + /** JDBC 驱动类 */ + private String driver = "com.mysql.jdbc.Driver"; + + /** JDBC URL */ + private String url; + + /** 用户名 */ + private String username; + + /** 密码 */ + private String password; +} diff --git a/src/main/java/com/example/homeutils/system/config/DatasourceConfigManager.java b/src/main/java/com/example/homeutils/system/config/DatasourceConfigManager.java new file mode 100644 index 0000000..c316c96 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/DatasourceConfigManager.java @@ -0,0 +1,84 @@ +package com.example.homeutils.system.config; + +import com.alibaba.fastjson.JSON; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Loads datasource definitions from data/datasources.json. + */ +@Slf4j +@Component +public class DatasourceConfigManager { + + private final HomeUtilsProperties properties; + private final List datasources = new ArrayList<>(); + + public DatasourceConfigManager(HomeUtilsProperties properties) { + this.properties = properties; + } + + @PostConstruct + void init() { + reload(); + } + + public synchronized void reload() { + datasources.clear(); + Path configFile = getConfigFile(); + if (!Files.exists(configFile)) { + log.warn("Datasource config file does not exist: {}", configFile.toAbsolutePath()); + return; + } + + try { + String json = new String(Files.readAllBytes(configFile), StandardCharsets.UTF_8); + List loaded = JSON.parseArray(json, DatasourceConfig.class); + if (loaded != null) { + for (DatasourceConfig datasource : loaded) { + if (datasource != null && datasource.getName() != null + && !datasource.getName().trim().isEmpty()) { + datasources.add(datasource); + } + } + } + log.info("Loaded {} datasource configs", datasources.size()); + } catch (IOException e) { + log.error("Failed to read datasource config: {}", e.getMessage()); + } catch (RuntimeException e) { + log.error("Failed to parse datasource config: {}", e.getMessage()); + } + } + + public List getDatasourceNames() { + List names = new ArrayList<>(); + for (DatasourceConfig ds : datasources) { + names.add(ds.getName()); + } + return names; + } + + public DatasourceConfig findByName(String name) { + if (name == null) { + return null; + } + for (DatasourceConfig ds : datasources) { + if (name.equals(ds.getName())) { + return ds; + } + } + return null; + } + + public Path getConfigFile() { + return properties.getDatasourcesFilePath(); + } +} diff --git a/src/main/java/com/example/homeutils/system/config/ExternalConfigManager.java b/src/main/java/com/example/homeutils/system/config/ExternalConfigManager.java new file mode 100644 index 0000000..ca9d63e --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/ExternalConfigManager.java @@ -0,0 +1,175 @@ +package com.example.homeutils.system.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +/** + * 外部配置文件管理器 + *

+ * 管理 data/ 目录下的 homeutils.config 文件: + *

    + *
  • 首次运行时由 HomeUtilsProperties 从 templates/ 复制到 data/
  • + *
  • 运行时读写,优先级高于 application.yml
  • + *
  • 支持热重载
  • + *
  • 手动写入避免中文 Unicode 转义
  • + *
+ */ +@Slf4j +@Component +public class ExternalConfigManager { + + private final HomeUtilsProperties properties; + + public ExternalConfigManager(HomeUtilsProperties properties) { + this.properties = properties; + } + + // ---- 系统路径 ---- + public static final String KEY_BASE_DIR = "base-directory"; + public static final String KEY_EXPORT_DIR = "export-directory"; + + // ---- 数据文件(绝对路径) ---- + public static final String KEY_PROJECT_PATHS = "project-paths-file"; + public static final String KEY_DAMAGED_REPOS = "damaged-repos-file"; + + // ---- MyBatis 数据库 ---- + public static final String KEY_MYBATIS_DRIVER = "mybatis.db.driver"; + public static final String KEY_MYBATIS_URL = "mybatis.db.url"; + public static final String KEY_MYBATIS_USERNAME = "mybatis.db.username"; + public static final String KEY_MYBATIS_PASSWORD = "mybatis.db.password"; + + // ---- MyBatis 代码生成包路径 ---- + public static final String KEY_MYBATIS_MODEL_PKG = "mybatis.model.package"; + public static final String KEY_MYBATIS_DAO_PKG = "mybatis.dao.package"; + public static final String KEY_MYBATIS_SQLMAPPER_PKG = "mybatis.sqlmapper.package"; + + // ---- 字体大小(显示设置) ---- + public static final String KEY_FONT_H1 = "font.size.h1"; // 页面标题 默认28 + public static final String KEY_FONT_H2 = "font.size.h2"; // 区块标题 默认22 + public static final String KEY_FONT_H3 = "font.size.h3"; // 子标题 默认18 + public static final String KEY_FONT_BODY = "font.size.body"; // 正文 默认15 + public static final String KEY_FONT_SMALL = "font.size.small"; // 辅助文字 默认13 + public static final String KEY_FONT_CAPTION = "font.size.caption"; // 脚注 默认11 + public static final String KEY_FONT_MONO = "font.size.mono"; // 代码 默认14 + public static final String KEY_FONT_BTN_LG = "font.size.btn-lg"; // 主按钮 默认16 + public static final String KEY_FONT_BTN = "font.size.btn"; // 普通按钮 默认14 + public static final String KEY_FONT_BTN_SM = "font.size.btn-sm"; // 小按钮 默认12 + + // 旧键别名(向后兼容,读取 homeutils.config 中旧格式) + /** @deprecated 使用 {@link #KEY_FONT_H2} */ + @Deprecated public static final String KEY_FONT_SECTION_TITLE = KEY_FONT_H2; + /** @deprecated 使用 {@link #KEY_FONT_BODY} */ + @Deprecated public static final String KEY_FONT_FIELD_LABEL = KEY_FONT_BODY; + /** @deprecated 使用 {@link #KEY_FONT_BTN} */ + @Deprecated public static final String KEY_FONT_BUTTON = KEY_FONT_BTN; + // KEY_FONT_BODY 和 KEY_FONT_MONO 键名不变,直接复用 + + // ---- 默认数据源 ---- + public static final String KEY_DEFAULT_DATASOURCE = "default.datasource"; + + /** 配置节定义:名称 → 键列表(MYBATIS 相关配置已迁移到 data/mybatis-generator.properties 独立管理) */ + private static final LinkedHashMap SECTIONS = new LinkedHashMap<>(); + + static { + SECTIONS.put("===== 系统路径 =====", new String[]{ + KEY_BASE_DIR, KEY_EXPORT_DIR + }); + SECTIONS.put("===== 数据文件路径 =====", new String[]{ + KEY_PROJECT_PATHS, KEY_DAMAGED_REPOS + }); + SECTIONS.put("===== 字体大小(显示设置) =====", new String[]{ + KEY_FONT_H1, KEY_FONT_H2, KEY_FONT_H3, + KEY_FONT_BODY, KEY_FONT_SMALL, KEY_FONT_CAPTION, KEY_FONT_MONO, + KEY_FONT_BTN_LG, KEY_FONT_BTN, KEY_FONT_BTN_SM + }); + SECTIONS.put("===== 默认数据源 =====", new String[]{ + KEY_DEFAULT_DATASOURCE + }); + } + + /** 获取配置文件路径 */ + public Path getConfigFile() { + return properties.getHomeutilsConfigFilePath(); + } + + public boolean exists() { + return Files.exists(getConfigFile()); + } + + /** 加载外部配置(仍用 Properties.load 解析,传入时已是 UTF-8 Reader) */ + public Properties load() { + Properties props = new Properties(); + Path file = getConfigFile(); + if (!Files.exists(file)) { + return props; + } + try (Reader r = new InputStreamReader( + new FileInputStream(file.toFile()), StandardCharsets.UTF_8)) { + props.load(r); + log.info("已加载外部配置: {}", file.toAbsolutePath()); + } catch (IOException e) { + log.error("读取外部配置失败: {}", file.toAbsolutePath(), e); + } + return props; + } + + /** + * 分段保存配置 —— 不转义中文 + * @param props 要保存的键值对(只写非空值) + */ + public void saveSectioned(Properties props) { + Path file = getConfigFile(); + try { + Files.createDirectories(file.getParent()); + try (PrintWriter w = new PrintWriter( + new OutputStreamWriter(new FileOutputStream(file.toFile()), StandardCharsets.UTF_8))) { + w.println("# ====== homeutils 外部配置文件 ======"); + w.println("# 编辑后通过「系统设置 > 重新加载」生效,无需重启"); + w.println(); + + for (Map.Entry section : SECTIONS.entrySet()) { + w.println("# " + section.getKey()); + boolean hasContent = false; + for (String key : section.getValue()) { + String val = props.getProperty(key); + if (val != null) { + w.println(key + "=" + val); + hasContent = true; + } + } + if (!hasContent) { + // 空节也列出键名方便用户手工填写 + for (String key : section.getValue()) { + w.println("#" + key + "="); + } + } + w.println(); + } + } + log.info("已保存外部配置: {}", file.toAbsolutePath()); + } catch (IOException e) { + log.error("保存外部配置失败: {}", file.toAbsolutePath(), e); + throw new RuntimeException("无法保存配置: " + e.getMessage(), e); + } + } + + /** 删除外部配置文件 */ + public void delete() { + Path file = getConfigFile(); + try { + Files.deleteIfExists(file); + log.info("已删除外部配置文件: {}", file.toAbsolutePath()); + } catch (IOException e) { + log.warn("删除外部配置失败: {}", file.toAbsolutePath(), e); + } + } + +} diff --git a/src/main/java/com/example/homeutils/system/config/HomeUtilsProperties.java b/src/main/java/com/example/homeutils/system/config/HomeUtilsProperties.java new file mode 100644 index 0000000..591d33f --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/HomeUtilsProperties.java @@ -0,0 +1,420 @@ +package com.example.homeutils.system.config; + +import com.example.homeutils.system.support.ApplicationPaths; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; + + +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import java.util.jar.JarFile; + +/** + * 工具集配置属性 + *

+ * 优先级: homeutils.config(外部) > application.yml(内置) > 默认值 + *

+ * 数据文件(project-paths-file / damaged-repos-file)始终解析到 user.dir 同级目录。 + * 外部配置中可使用绝对路径覆盖,或使用相对路径(相对于 user.dir)。 + * + * @author system + * @version 2.2 + */ +@Slf4j +@Component +@ConfigurationProperties(prefix = "homeutils") +@Data +public class HomeUtilsProperties { + + private final ApplicationPaths applicationPaths; + + public HomeUtilsProperties() { + this(ApplicationPaths.current()); + } + + HomeUtilsProperties(ApplicationPaths applicationPaths) { + this.applicationPaths = applicationPaths; + } + + /** 基础目录 */ + private String baseDirectory; + + /** 导出文件根目录 */ + private String exportDirectory; + + /** 项目路径配置文件(相对路径时相对于 user.dir) */ + private String projectPathsFile; + + /** 损坏仓库缓存文件(相对路径时相对于 user.dir) */ + private String damagedReposFile; + + /** 数据源配置文件(相对路径时相对于 user.dir) */ + private String datasourcesFile; + + /** 外部配置文件(相对路径时相对于 user.dir) */ + private String homeutilsConfigFile; + + /** MyBatis 生成器配置文件(相对路径时相对于 user.dir) */ + private String mybatisGeneratorFile; + + /** 插件状态配置文件(相对路径时相对于 user.dir) */ + private String pluginsConfigFile; + + /** API 基类配置文件(相对路径时相对于 user.dir) */ + private String apiBaseClassesFile; + + /** 工具启用/禁用配置 */ + private ToolsConfig tools; + + /** application.yml 中的原始值快照(用于对比和回退) */ + private final Map defaults = new LinkedHashMap<>(); + + /** MyBatis 外部覆盖配置 */ + private final Map mybatisOverrides = new LinkedHashMap<>(); + + // ---- 字体大小(显示设置)10级 Token 体系 ---- + private int fontH1 = 28; // 页面主标题 + private int fontH2 = 22; // 区块标题 + private int fontH3 = 18; // 子标题 + private int fontBody = 15; // 正文/标签 + private int fontSmall = 13; // 辅助文字/提示 + private int fontCaption = 11; // 脚注/版本号 + private int fontMono = 14; // 等宽/代码 + private int fontBtnLg = 16; // 主按钮 + private int fontBtn = 14; // 普通按钮 + private int fontBtnSm = 12; // 小按钮 + + // ---- 向后兼容旧名 getter(供未迁移的窗口文件使用) ---- + @Deprecated public int getFontSectionTitle() { return fontH2; } + @Deprecated public int getFontFieldLabel() { return fontBody; } + @Deprecated public int getFontButton() { return fontBtn; } + // getFontBody() / getFontMono() 由 Lombok 按新字段名自动生成 + + // ---- 默认数据源 ---- + private String defaultDatasource; + + // ==================== 外部配置加载 ==================== + + @PostConstruct + void snapshotDefaults() { + // 基础目录:未配置则默认取运行目录(user.dir) + if (baseDirectory == null || baseDirectory.trim().isEmpty()) { + baseDirectory = applicationPaths.home().toString(); + log.info("基础目录未配置,默认取运行目录: {}", baseDirectory); + } + // 导出目录:未配置则默认 {user.dir}/export + if (exportDirectory == null || exportDirectory.trim().isEmpty()) { + exportDirectory = applicationPaths.resolve("export").toString(); + log.info("导出目录未配置,默认: {}", exportDirectory); + ensureExportDirExists(); + } else if (!Paths.get(exportDirectory).isAbsolute()) { + // 相对路径 → 解析到 user.dir 下 + exportDirectory = applicationPaths.resolveConfigured(exportDirectory).toString(); + ensureExportDirExists(); + } else { + ensureExportDirExists(); + } + + defaults.put(ExternalConfigManager.KEY_BASE_DIR, baseDirectory); + defaults.put(ExternalConfigManager.KEY_EXPORT_DIR, exportDirectory); + defaults.put(ExternalConfigManager.KEY_PROJECT_PATHS, projectPathsFile); + defaults.put(ExternalConfigManager.KEY_DAMAGED_REPOS, damagedReposFile); + // 首次运行时确保所有数据文件存在于 user.dir 同级 data/ 目录 + ensureAllDataFilesExist(); + } + + /** 确保导出目录存在 */ + private void ensureExportDirExists() { + try { + Path dir = Paths.get(exportDirectory); + if (!Files.exists(dir)) { + Files.createDirectories(dir); + log.info("已创建导出目录: {}", dir.toAbsolutePath()); + } + } catch (IOException e) { + log.warn("创建导出目录失败: {} ({})", exportDirectory, e.getMessage()); + } + } + + /** + * 合并外部配置 —— 外部值覆盖 application.yml 值 + */ + public void mergeExternal(Properties external) { + if (external == null || external.isEmpty()) return; + + applyIfPresent(external, ExternalConfigManager.KEY_BASE_DIR, v -> this.baseDirectory = v); + applyIfPresent(external, ExternalConfigManager.KEY_EXPORT_DIR, v -> this.exportDirectory = v); + applyIfPresent(external, ExternalConfigManager.KEY_PROJECT_PATHS, v -> this.projectPathsFile = v); + applyIfPresent(external, ExternalConfigManager.KEY_DAMAGED_REPOS, v -> this.damagedReposFile = v); + + // MyBatis 覆盖项 + String[] mybatisKeys = { + ExternalConfigManager.KEY_MYBATIS_DRIVER, + ExternalConfigManager.KEY_MYBATIS_URL, + ExternalConfigManager.KEY_MYBATIS_USERNAME, + ExternalConfigManager.KEY_MYBATIS_PASSWORD, + ExternalConfigManager.KEY_MYBATIS_MODEL_PKG, + ExternalConfigManager.KEY_MYBATIS_DAO_PKG, + ExternalConfigManager.KEY_MYBATIS_SQLMAPPER_PKG + }; + mybatisOverrides.clear(); + for (String key : mybatisKeys) { + String val = external.getProperty(key); + if (val != null && !val.isEmpty()) { + mybatisOverrides.put(key, val); + } + } + + // 字体大小(新键优先,旧键作为 fallback) + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_H1, v -> this.fontH1 = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_H2, v -> this.fontH2 = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_H3, v -> this.fontH3 = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_BODY, v -> this.fontBody = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_SMALL, v -> this.fontSmall = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_CAPTION, v -> this.fontCaption = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_MONO, v -> this.fontMono = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_BTN_LG, v -> this.fontBtnLg = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_BTN, v -> this.fontBtn = v); + applyIntIfPresent(external, ExternalConfigManager.KEY_FONT_BTN_SM, v -> this.fontBtnSm = v); + + // 默认数据源 + applyIfPresent(external, ExternalConfigManager.KEY_DEFAULT_DATASOURCE, v -> this.defaultDatasource = v); + + log.info("外部配置已合并: base={}, export={}, projectPaths={}, damagedRepos={}, mybatisOverrides={}, " + + "fontSizes=[h1={},h2={},h3={},body={},small={},caption={},mono={},btnLg={},btn={},btnSm={}]", + baseDirectory, exportDirectory, projectPathsFile, damagedReposFile, mybatisOverrides.size(), + fontH1, fontH2, fontH3, fontBody, fontSmall, fontCaption, fontMono, fontBtnLg, fontBtn, fontBtnSm); + } + + /** 回退到 application.yml 默认值 */ + public void resetToDefaults() { + baseDirectory = defaults.get(ExternalConfigManager.KEY_BASE_DIR); + exportDirectory = defaults.get(ExternalConfigManager.KEY_EXPORT_DIR); + projectPathsFile = defaults.get(ExternalConfigManager.KEY_PROJECT_PATHS); + damagedReposFile = defaults.get(ExternalConfigManager.KEY_DAMAGED_REPOS); + mybatisOverrides.clear(); + fontH1 = 28; + fontH2 = 22; + fontH3 = 18; + fontBody = 15; + fontSmall = 13; + fontCaption = 11; + fontMono = 14; + fontBtnLg = 16; + fontBtn = 14; + fontBtnSm = 12; + defaultDatasource = null; + log.info("配置已重置为默认值"); + } + + // ==================== MyBatis 覆盖查询 ==================== + + public String getEffectiveMybatisDbUrl() { + return mybatisOverrides.get(ExternalConfigManager.KEY_MYBATIS_URL); + } + + public String getEffectiveMybatisDbUsername() { + return mybatisOverrides.get(ExternalConfigManager.KEY_MYBATIS_USERNAME); + } + + // ==================== 路径解析方法 ==================== + + /** + * 项目路径文件的实际路径 + *

+ * 始终以 user.dir(exe 同级目录)为基准: + * 如果 projectPathsFile 已是绝对路径,直接使用; + * 否则解析到 user.dir 下。 + * 文件不存在时自动创建空文件。 + */ + public Path getProjectPathsFilePath() { + return applicationPaths.resolveConfigured(projectPathsFile, "data", "project_paths.txt"); + } + + /** + * 损坏仓库缓存文件的实际路径 + *

+ * 始终以 user.dir(exe 同级目录)为基准: + * 如果 damagedReposFile 已是绝对路径,直接使用; + * 否则解析到 user.dir 下。 + */ + public Path getDamagedReposFilePath() { + return applicationPaths.resolveConfigured(damagedReposFile, "data", "damaged_repos.cache"); + } + + /** + * 数据源配置文件的实际路径 + */ + public Path getDatasourcesFilePath() { + return applicationPaths.resolveConfigured(datasourcesFile, "data", "datasources.json"); + } + + /** + * 外部配置文件(homeutils.config)的实际路径 + */ + public Path getHomeutilsConfigFilePath() { + return applicationPaths.resolveConfigured(homeutilsConfigFile, "data", "homeutils.config"); + } + + /** + * MyBatis 生成器配置文件的实际路径 + */ + public Path getMybatisGeneratorFilePath() { + return applicationPaths.resolveConfigured(mybatisGeneratorFile, "data", "mybatis-generator.properties"); + } + + /** + * 插件状态配置文件的实际路径 + */ + public Path getPluginsConfigFilePath() { + return applicationPaths.resolveConfigured(pluginsConfigFile, "data", "plugins.config"); + } + + /** + * API 基类配置文件的实际路径 + */ + public Path getApiBaseClassesFilePath() { + return applicationPaths.resolveConfigured(apiBaseClassesFile, "data", "api_base_classes.json"); + } + + /** + * 确保基础数据文件存在于 user.dir 同级 data/ 目录。 + * 启动时统一检查:不存在则从 classpath:templates/ 下复制模板。 + * 仅保留外部配置文件与插件配置;其余数据文件(数据源、项目路径、 + * 损坏缓存、MyBatis 生成器、API 基类)由各自功能按需创建。 + */ + private void ensureAllDataFilesExist() { + ensureDataFile(getHomeutilsConfigFilePath(), "templates/homeutils.config", "外部配置文件"); + ensureDataFile(getPluginsConfigFilePath(), "templates/plugins.config", "插件配置文件"); + } + + private void ensureDataFile(Path target, String classpathResource, String label) { + try { + // 文件存在且非空 → 跳过;不存在或为空(0字节)→ 重新复制模板 + if (Files.exists(target) && Files.size(target) > 0) { + log.debug("{} 已存在且非空,跳过复制: {}", label, target.toAbsolutePath()); + return; + } + Files.createDirectories(target.getParent()); + + // 多 ClassLoader 回退,兼容 fat JAR / thin JAR / IDE 环境 + InputStream in = openClasspathStream(classpathResource); + if (in != null) { + try (in) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + log.info("已从模板复制{}: {} ({} bytes)", label, target.toAbsolutePath(), Files.size(target)); + } + } else { + // 所有 ClassLoader 都未找到模板,保留空文件防下次重试 + if (!Files.exists(target)) Files.createFile(target); + log.warn("模板未找到[classpath:{}],已创建空{}: {}", classpathResource, label, target.toAbsolutePath()); + } + } catch (java.io.IOException e) { + log.warn("创建{}失败: {}", label, e.getMessage()); + } + } + + /** + * 多 ClassLoader 回退打开 classpath 资源流。 + * 优先级:ContextClassLoader → 当前类的 ClassLoader → 系统 ClassLoader → 直读 JAR + */ + private static InputStream openClasspathStream(String path) { + InputStream in; + + // 1) Thread ContextClassLoader(Spring Boot fat JAR 标准) + ClassLoader ctxCl = Thread.currentThread().getContextClassLoader(); + if (ctxCl != null) { + in = ctxCl.getResourceAsStream(path); + if (in != null) return in; + } + + // 2) 当前类的 ClassLoader + in = HomeUtilsProperties.class.getClassLoader().getResourceAsStream(path); + if (in != null) return in; + + // 3) 系统 ClassLoader(兜底) + in = ClassLoader.getSystemResourceAsStream(path); + if (in != null) return in; + + // 4) 直接从 JAR 文件读取(终极回退,绕过所有 ClassLoader 问题) + try { + URL jarUrl = HomeUtilsProperties.class.getProtectionDomain().getCodeSource().getLocation(); + if (jarUrl != null) { + String jarPath = jarUrl.getPath(); + String entryName = "BOOT-INF/classes/" + path; + try (JarFile jar = new JarFile(jarPath)) { + java.util.jar.JarEntry entry = jar.getJarEntry(entryName); + if (entry != null) { + byte[] bytes = jar.getInputStream(entry).readAllBytes(); + log.info("从 JAR 直接读取模板: {} ({} bytes)", entryName, bytes.length); + return new ByteArrayInputStream(bytes); + } + } + } + } catch (Exception ignored) { + // 不是 JAR 环境或读取失败,忽略,前面 ClassLoader 方式已尽力 + } + + return null; + } + + public Path getExportBasePath() { + return Paths.get(exportDirectory); + } + + public Path getTodayExportPath() { + return getExportBasePath().resolve(LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd"))); + } + + public Path resolveExportPath(String fileName) { + return getTodayExportPath().resolve(fileName); + } + + public Path resolveExportPath(String prefix, String extension) { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + return resolveExportPath(String.format("%s_%s.%s", prefix, timestamp, extension)); + } + + // ==================== 工具方法 ==================== + + private void applyIfPresent(Properties props, String key, java.util.function.Consumer setter) { + String val = props.getProperty(key); + if (val != null && !val.trim().isEmpty()) { + setter.accept(val.trim()); + } + } + + private void applyIntIfPresent(Properties props, String key, java.util.function.IntConsumer setter) { + String val = props.getProperty(key); + if (val != null && !val.trim().isEmpty()) { + try { + setter.accept(Integer.parseInt(val.trim())); + } catch (NumberFormatException ignored) { } + } + } + + // ==================== 内部配置类 ==================== + + @Data + public static class ToolsConfig { + private List enabled; + private List disabled; + } +} diff --git a/src/main/java/com/example/homeutils/system/config/SettingsService.java b/src/main/java/com/example/homeutils/system/config/SettingsService.java new file mode 100644 index 0000000..14ab763 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/SettingsService.java @@ -0,0 +1,55 @@ +package com.example.homeutils.system.config; + +import com.example.homeutils.system.plugin.PluginManager; +import com.example.homeutils.system.ui.MainWindow; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.function.Consumer; + +/** + * 系统设置服务 —— 通过可视化 GUI 修改配置 + *

+ * 负责: + *

    + *
  • 打开 JavaFX 设置窗口
  • + *
  • 配置变更后通知调用方
  • + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SettingsService { + + private final HomeUtilsProperties properties; + private final ExternalConfigManager configManager; + private final DatasourceConfigManager datasourceConfigManager; + private final PluginManager pluginManager; + + /** 导航栏刷新回调(插件启用/禁用时刷新侧边栏) */ + private Consumer> navItemsRefresher; + + /** 设置导航栏刷新回调 */ + public void setNavItemsRefresher(Consumer> refresher) { + this.navItemsRefresher = refresher; + } + + /** + * 创建设置窗口(不自动 showAndWait),供内嵌模式使用 + */ + public SettingsWindow createSettingsWindow(Runnable onConfigChanged) { + return new SettingsWindow(properties, configManager, datasourceConfigManager, + pluginManager, onConfigChanged, navItemsRefresher); + } + + /** + * 通知导航栏刷新(供外部调用) + */ + public void refreshNavItems(List items) { + if (navItemsRefresher != null) { + navItemsRefresher.accept(items); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/config/SettingsWindow.java b/src/main/java/com/example/homeutils/system/config/SettingsWindow.java new file mode 100644 index 0000000..1b4e1ba --- /dev/null +++ b/src/main/java/com/example/homeutils/system/config/SettingsWindow.java @@ -0,0 +1,1159 @@ +package com.example.homeutils.system.config; + +import com.example.homeutils.system.plugin.Plugin; +import com.example.homeutils.system.plugin.PluginDescriptor; +import com.example.homeutils.system.plugin.PluginManagementWindow; +import com.example.homeutils.system.plugin.PluginManager; +import com.example.homeutils.system.support.JfxBootstrap; +import com.example.homeutils.system.support.SystemOpenHelper; +import com.example.homeutils.system.ui.MainWindow; +import com.example.homeutils.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; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; +import javafx.scene.text.Font; +import javafx.stage.DirectoryChooser; +import javafx.stage.Screen; +import javafx.stage.Stage; +import lombok.extern.slf4j.Slf4j; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.CountDownLatch; + +/** + * 系统设置可视化窗口(AtlantaFX 重构版) + *

+ * 使用 CSS 类控制样式,支持亮色/暗色主题切换。 + */ +@Slf4j +public class SettingsWindow { + + private final HomeUtilsProperties properties; + private final ExternalConfigManager configManager; + private final DatasourceConfigManager datasourceConfigManager; + private final PluginManager pluginManager; + private final Runnable onConfigChanged; + + /** 导航栏刷新回调 */ + private final java.util.function.Consumer> navItemsRefresher; + + private Stage stage; + private Scene scene; + + // --- 项目路径替换工具 --- + private TextField tfReplaceParentDir, tfReplaceAccount; + private TextArea taProjectPathsPreview; + + // --- 系统默认数据源选择 --- + private ComboBox defaultDsCmb; + private Label dsDetailDriver, dsDetailUrl, dsDetailUser; + + // --- 字体大小(10 级 Token) --- + private int fontH1, fontH2, fontH3; + private int fontBody, fontSmall, fontCaption; + private int fontMono; + private int fontBtnLg, fontBtn, fontBtnSm; + + private final java.util.List

+ * 提供: + *

    + *
  • 插件元数据 — {@link #getDescriptor()} 声明 id/name/version/description
  • + *
  • 生命周期 — {@link #onLoad(PluginContext)} / {@link #onUnload()}
  • + *
  • 命令行模式 — {@link #getCommandName()} + {@link #executeCommand(String...)}
  • + *
  • 可视化窗口 — {@link #getVisualFactory()}
  • + *
+ */ +public interface Plugin { + + /** 插件描述符(元数据) */ + PluginDescriptor getDescriptor(); + + /** 工具唯一标识,默认委托给 PluginDescriptor */ + default String getId() { + return getDescriptor().getId(); + } + + /** 菜单显示名称,默认委托给 PluginDescriptor */ + default String getName() { + return getDescriptor().getName(); + } + + /** 命令行命令名(如 "sql", "scan"),返回空字符串表示不支持命令行模式 */ + default String getCommandName() { + return ""; + } + + // ==================== 命令行模式 ==================== + + default void executeCommand(String... args) throws Exception { + System.out.println("工具 [" + getName() + "] 不支持命令行模式"); + } + + // ==================== 生命周期回调 ==================== + + /** 插件加载回调,PluginManager 在注册时调用 */ + default void onLoad(PluginContext context) { + } + + /** 插件卸载回调,清理资源 */ + default void onUnload() { + } + + // ==================== 配置检查 ==================== + + /** + * 检查插件所需的配置是否就绪。 + * 首次执行插件前由 PluginManager 调用,返回 null 表示就绪, + * 返回非空字符串则为错误提示,将展示给用户。 + */ + default String checkConfig() { + return null; + } + + // ==================== 可视化窗口 ==================== + + /** + * 返回可视化窗口工厂。返回 null 表示该插件不支持可视窗口, + * 主窗口中将显示为灰色禁用态。 + */ + default VisualPluginFactory getVisualFactory() { + return null; + } +} diff --git a/src/main/java/com/example/homeutils/system/plugin/PluginContext.java b/src/main/java/com/example/homeutils/system/plugin/PluginContext.java new file mode 100644 index 0000000..b18f9b4 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/PluginContext.java @@ -0,0 +1,61 @@ +package com.example.homeutils.system.plugin; + +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 插件运行时上下文 + *

+ * 通过 {@link Plugin#onLoad(PluginContext)} 注入给每个插件, + * 提供访问 PluginManager 和跨插件共享属性的能力。 + */ +public class PluginContext { + + private final PluginManager pluginManager; + private final Map attributes = new ConcurrentHashMap<>(); + + /** 已通过配置检查的插件ID集合(首次执行时检查一次) */ + private final Set configCheckedPlugins = ConcurrentHashMap.newKeySet(); + + public PluginContext(PluginManager pluginManager) { + this.pluginManager = pluginManager; + } + + /** 获取 PluginManager,用于插件间通信 */ + public PluginManager getPluginManager() { + return pluginManager; + } + + /** 设置共享属性 */ + public void setAttribute(String key, Object value) { + attributes.put(key, value); + } + + /** 获取共享属性 */ + @SuppressWarnings("unchecked") + public T getAttribute(String key) { + return (T) attributes.get(key); + } + + /** 获取共享属性,带默认值 */ + @SuppressWarnings("unchecked") + public T getAttribute(String key, T defaultValue) { + return (T) attributes.getOrDefault(key, defaultValue); + } + + /** 标记插件配置已检查 */ + public void markConfigChecked(String pluginId) { + configCheckedPlugins.add(pluginId); + } + + /** 插件配置是否已检查过 */ + public boolean isConfigChecked(String pluginId) { + return configCheckedPlugins.contains(pluginId); + } + + /** 重置所有配置检查记录(配置变更后调用) */ + public void resetConfigChecks() { + configCheckedPlugins.clear(); + } +} diff --git a/src/main/java/com/example/homeutils/system/plugin/PluginDescriptor.java b/src/main/java/com/example/homeutils/system/plugin/PluginDescriptor.java new file mode 100644 index 0000000..e5970fc --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/PluginDescriptor.java @@ -0,0 +1,78 @@ +package com.example.homeutils.system.plugin; + +import java.util.Collections; +import java.util.List; + +/** + * 插件描述符(不可变元数据) + *

+ * 每个插件通过 {@link Plugin#getDescriptor()} 返回此描述符, + * PluginManager 据此识别、管理、展示插件。 + */ +public class PluginDescriptor { + + private final String id; + private final String name; + private final String version; + private final String description; + private final List dependencies; + private final int order; + private final String icon; + + private PluginDescriptor(Builder builder) { + this.id = builder.id; + this.name = builder.name; + this.version = builder.version; + this.description = builder.description; + this.dependencies = Collections.unmodifiableList(builder.dependencies); + this.order = builder.order; + this.icon = builder.icon; + } + + public static Builder builder() { + return new Builder(); + } + + // ==================== Getters ==================== + + public String getId() { return id; } + public String getName() { return name; } + 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; } + + @Override + public String toString() { + return name + " [v" + version + "]"; + } + + // ==================== Builder ==================== + + public static class Builder { + private String id; + private String name; + private String version = "1.0.0"; + 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; } + public Builder version(String version) { this.version = version;return this; } + 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"); + if (name == null || name.isEmpty()) throw new IllegalArgumentException("plugin name is required"); + return new PluginDescriptor(this); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/plugin/PluginManagementWindow.java b/src/main/java/com/example/homeutils/system/plugin/PluginManagementWindow.java new file mode 100644 index 0000000..830c58a --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/PluginManagementWindow.java @@ -0,0 +1,377 @@ +package com.example.homeutils.system.plugin; + +import com.example.homeutils.system.ui.Theme; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Cursor; +import javafx.scene.Scene; +import javafx.scene.control.*; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.input.*; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.stage.Modality; +import javafx.stage.Stage; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +/** + * 插件管理窗口 —— 支持拖拽排序、启用/禁用、保存到 data/plugins.config + *

+ * 更改暂存于内存,点击「保存并刷新菜单」后持久化并生效。 + */ +public class PluginManagementWindow { + + private final PluginManager pluginManager; + private final Consumer> onSaveCallback; + private Stage stage; + + /** 当前内存中的插件条目(同步自 PluginManager + 本地编辑) */ + private final List pluginItems = new ArrayList<>(); + + /** 行索引 -> HBox 映射,用于拖拽交换 */ + private final Map rowMap = new LinkedHashMap<>(); + + /** 插件列表容器 */ + private VBox pluginListBox; + + /** 拖拽相关 */ + private int dragSourceIndex = -1; + private static final DataFormat DRAG_FORMAT = new DataFormat("plugin-row-index"); + + public PluginManagementWindow(PluginManager pluginManager, + Consumer> onSaveCallback) { + this.pluginManager = pluginManager; + this.onSaveCallback = onSaveCallback; + loadCurrentState(); + buildUI(); + } + + // ==================== 数据模型 ==================== + + public static class PluginItem { + private final String id; + private final String icon; + private final String name; + private final String description; + private boolean enabled; + + public PluginItem(String id, String icon, String name, String description, boolean enabled) { + this.id = id; + this.icon = icon; + this.name = name; + this.description = description; + this.enabled = enabled; + } + + public String getId() { return id; } + public String getIcon() { return icon; } + public String getName() { return name; } + public String getDescription() { return description; } + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean e) { this.enabled = e; } + } + + /** 从 PluginManager 加载当前状态 */ + private void loadCurrentState() { + pluginItems.clear(); + List enabledPlugins = pluginManager.getEnabledPlugins(); + java.util.Set enabledIds = new java.util.HashSet<>(); + for (Plugin p : enabledPlugins) { + enabledIds.add(p.getId()); + } + + // 收集所有插件描述符 + Map allDescMap = new LinkedHashMap<>(); + for (PluginDescriptor desc : pluginManager.listAllPluginDescriptors()) { + allDescMap.put(desc.getId(), desc); + } + + // 从 PluginManager 的 enabledPlugins 获取顺序 + for (Plugin p : enabledPlugins) { + PluginDescriptor desc = p.getDescriptor(); + pluginItems.add(new PluginItem(desc.getId(), desc.getIcon(), + desc.getName(), desc.getDescription(), true)); + } + + // 追加已禁用的插件(排在最后,按描述符 order 排序) + List disabledDescs = new ArrayList<>(); + for (PluginDescriptor desc : pluginManager.listAllPluginDescriptors()) { + if (!enabledIds.contains(desc.getId())) { + disabledDescs.add(desc); + } + } + disabledDescs.sort(java.util.Comparator.comparingInt(d -> d.getOrder())); + for (PluginDescriptor desc : disabledDescs) { + pluginItems.add(new PluginItem(desc.getId(), desc.getIcon(), + desc.getName(), desc.getDescription(), false)); + } + } + + // ==================== UI 构建 ==================== + + private void buildUI() { + stage = new Stage(); + stage.setTitle("插件管理"); + stage.initModality(Modality.APPLICATION_MODAL); + stage.setMinWidth(600); + stage.setMinHeight(450); + + VBox root = new VBox(12); + root.setPadding(new Insets(16)); + root.getStyleClass().add(Theme.CSS_CONTENT_AREA); + + // 标题 + Label title = new Label("插件管理"); + title.setFont(Theme.bold(22)); + title.getStyleClass().add(Theme.CSS_SECTION_TITLE); + root.getChildren().add(title); + + Label hint = new Label("拖拽左侧手柄可调整插件顺序。启用/禁用在点击「保存并刷新菜单」后生效。"); + hint.setFont(Theme.base(13)); + hint.getStyleClass().add(Theme.CSS_HINT_TEXT); + root.getChildren().add(hint); + + // 插件列表 + pluginListBox = new VBox(4); + refreshPluginRows(); + + ScrollPane scrollPane = new ScrollPane(pluginListBox); + scrollPane.setFitToWidth(true); + scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + VBox.setVgrow(scrollPane, Priority.ALWAYS); + root.getChildren().add(scrollPane); + + // 按钮行 + HBox btnRow = new HBox(10); + btnRow.setAlignment(Pos.CENTER_RIGHT); + + Button btnCancel = new Button("取消"); + btnCancel.setFont(Theme.base(14)); + btnCancel.getStyleClass().add(Theme.CSS_BTN_SMALL); + btnCancel.setOnAction(e -> stage.close()); + + Button btnSave = new Button("保存并刷新菜单"); + btnSave.setFont(Theme.base(14)); + btnSave.getStyleClass().add(Theme.CSS_BTN_SUCCESS); + btnSave.setOnAction(e -> saveAndRefresh()); + + // 重置按钮 + Button btnReset = new Button("重置"); + btnReset.setFont(Theme.base(14)); + btnReset.getStyleClass().add(Theme.CSS_BTN_SMALL); + btnReset.setOnAction(e -> { + loadCurrentState(); + refreshPluginRows(); + }); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + btnRow.getChildren().addAll(btnReset, spacer, btnCancel, btnSave); + root.getChildren().add(btnRow); + + Scene scene = new Scene(root, 620, 500); + Theme.applyCustomCss(scene); + stage.setScene(scene); + } + + /** 根据 pluginItems 重建所有行 */ + private void refreshPluginRows() { + rowMap.clear(); + pluginListBox.getChildren().clear(); + + for (int i = 0; i < pluginItems.size(); i++) { + PluginItem item = pluginItems.get(i); + HBox row = createPluginRow(i, item); + rowMap.put(i, row); + pluginListBox.getChildren().add(row); + } + } + + /** 创建单行插件条目 */ + private HBox createPluginRow(int index, PluginItem item) { + HBox row = new HBox(8); + row.setAlignment(Pos.CENTER_LEFT); + row.setPadding(new Insets(6, 10, 6, 10)); + row.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + + "; -fx-background-radius: " + Theme.RADIUS_SM + ";"); + + // 拖拽手柄 + Label gripIcon = new Label("≡"); + gripIcon.setFont(Theme.base(18)); + gripIcon.setTextFill(Theme.TEXT_MUTED); + gripIcon.setCursor(Cursor.MOVE); + gripIcon.setPadding(new Insets(0, 2, 0, 0)); + gripIcon.setUserData(index); + + gripIcon.setOnDragDetected(e -> { + Dragboard db = gripIcon.startDragAndDrop(TransferMode.MOVE); + ClipboardContent cc = new ClipboardContent(); + cc.put(DRAG_FORMAT, index); + db.setContent(cc); + dragSourceIndex = index; + row.setOpacity(0.4); + e.consume(); + }); + + gripIcon.setOnDragDone(e -> { + row.setOpacity(1.0); + dragSourceIndex = -1; + // 恢复所有行透明度 + for (HBox r : rowMap.values()) r.setOpacity(1.0); + e.consume(); + }); + + // 图标 + Label iconLabel = new Label(item.getIcon()); + iconLabel.setFont(Theme.base(16)); + + // 名称(悬浮显示描述) + Label nameLabel = new Label(item.getName()); + nameLabel.setFont(Theme.bold(14)); + if (item.getDescription() != null && !item.getDescription().isEmpty()) { + Tooltip tip = new Tooltip(item.getDescription()); + tip.setFont(Theme.base(12)); + tip.setStyle("-fx-background-color: #1e293b; -fx-text-fill: #f1f5f9; -fx-background-radius: 4; -fx-padding: 6 10;"); + nameLabel.setTooltip(tip); + } + + // 禁用状态下的文字变灰 + if (!item.isEnabled()) { + nameLabel.setTextFill(Color.GRAY); + iconLabel.setTextFill(Color.GRAY); + } else { + nameLabel.setTextFill(Theme.TEXT_PRIMARY); + } + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + // 启用/禁用开关 + ToggleButton toggleBtn = new ToggleButton(item.isEnabled() ? "已启用" : "已禁用"); + toggleBtn.setSelected(item.isEnabled()); + toggleBtn.setFont(Theme.base(12)); + toggleBtn.getStyleClass().add(Theme.CSS_TOGGLE_BTN); + + final Label fn = nameLabel; + final Label fi = iconLabel; + toggleBtn.setOnAction(e -> { + item.setEnabled(toggleBtn.isSelected()); + toggleBtn.setText(toggleBtn.isSelected() ? "已启用" : "已禁用"); + if (toggleBtn.isSelected()) { + fn.setTextFill(Theme.TEXT_PRIMARY); + fi.setTextFill(Theme.TEXT_PRIMARY); + } else { + fn.setTextFill(Color.GRAY); + fi.setTextFill(Color.GRAY); + } + }); + + row.getChildren().addAll(gripIcon, iconLabel, nameLabel, spacer, toggleBtn); + + // 拖放接收 + row.setOnDragOver(e -> { + if (e.getGestureSource() != null + && e.getDragboard().hasContent(DRAG_FORMAT) + && !row.equals(e.getGestureSource())) { + e.acceptTransferModes(TransferMode.MOVE); + } + e.consume(); + }); + + row.setOnDragEntered(e -> { + if (e.getDragboard().hasContent(DRAG_FORMAT) + && dragSourceIndex >= 0 + && dragSourceIndex != index) { + row.setStyle("-fx-background-color: " + Theme.toWeb(Theme.PRIMARY_LIGHT) + + "; -fx-background-radius: " + Theme.RADIUS_SM + ";" + + " -fx-border-color: " + Theme.toWeb(Theme.PRIMARY) + + "; -fx-border-radius: " + Theme.RADIUS_SM + "; -fx-border-width: 1;"); + } + e.consume(); + }); + + row.setOnDragExited(e -> { + row.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + + "; -fx-background-radius: " + Theme.RADIUS_SM + ";"); + e.consume(); + }); + + row.setOnDragDropped(e -> { + Dragboard db = e.getDragboard(); + if (db.hasContent(DRAG_FORMAT)) { + int sourceIdx = (int) db.getContent(DRAG_FORMAT); + if (sourceIdx >= 0 && sourceIdx != index && sourceIdx < pluginItems.size()) { + // 交换元素 + PluginItem moved = pluginItems.remove(sourceIdx); + int targetIdx = sourceIdx < index ? index - 1 : index; + if (targetIdx > pluginItems.size()) targetIdx = pluginItems.size(); + pluginItems.add(targetIdx, moved); + refreshPluginRows(); + } + } + e.setDropCompleted(true); + e.consume(); + }); + + return row; + } + + /** 保存并刷新 */ + private void saveAndRefresh() { + try { + // 应用启用/禁用 + 顺序到 PluginManager + List orderedIds = new ArrayList<>(); + for (PluginItem item : pluginItems) { + orderedIds.add(item.getId()); + if (item.isEnabled()) { + pluginManager.enablePlugin(item.getId()); + } else { + pluginManager.disablePlugin(item.getId()); + } + } + pluginManager.reorderPlugins(orderedIds); + + // 保存到 data/plugins.config + pluginManager.savePluginConfig(pluginItems); + + // 回调通知外部刷新 + if (onSaveCallback != null) { + onSaveCallback.accept(new ArrayList<>(pluginItems)); + } + + showAlert(AlertType.INFORMATION, "插件配置已保存,菜单即将刷新。"); + stage.close(); + } catch (Exception ex) { + showAlert(AlertType.ERROR, "保存失败: " + ex.getMessage()); + } + } + + private void showAlert(AlertType type, String msg) { + Alert alert = new Alert(type); + alert.setTitle(type == AlertType.ERROR ? "错误" : "提示"); + alert.setHeaderText(null); + alert.setContentText(msg); + alert.showAndWait(); + } + + /** 当前插件的启用/禁用状态列表(用于外部查询当前编辑结果) */ + public List getCurrentItems() { + return new ArrayList<>(pluginItems); + } + + // ==================== 显示入口 ==================== + + public void showAndWait() { + stage.showAndWait(); + } + + public Stage getStage() { + return stage; + } +} diff --git a/src/main/java/com/example/homeutils/system/plugin/PluginManager.java b/src/main/java/com/example/homeutils/system/plugin/PluginManager.java new file mode 100644 index 0000000..c2d7d53 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/PluginManager.java @@ -0,0 +1,332 @@ +package com.example.homeutils.system.plugin; + +import com.example.homeutils.system.config.HomeUtilsProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; + +/** + * 插件管理器 —— 插件生命周期管理 + 注册中心 + *

+ * 统一管理所有 {@link Plugin} 实例的: + *

    + *
  • 生命周期 — load / unload / enable / disable
  • + *
  • 注册发现 — 按 id / command 查找
  • + *
  • 启用过滤 — 白名单 / 黑名单
  • + *
  • 命令行分发 — 按 command 名路由
  • + *
+ */ +@Slf4j +@Component +public class PluginManager { + + private final List allPlugins; + private final HomeUtilsProperties properties; + private PluginContext pluginContext; + + private List enabledPlugins; + private Map pluginByIdMap; + private Map pluginByCommandMap; + private Map pluginStates; + + public PluginManager(List plugins, HomeUtilsProperties properties) { + this.allPlugins = plugins; + this.properties = properties; + } + + @PostConstruct + public void init() { + this.pluginContext = new PluginContext(this); + this.enabledPlugins = new ArrayList<>(); + this.pluginByIdMap = new LinkedHashMap<>(); + this.pluginByCommandMap = new LinkedHashMap<>(); + this.pluginStates = new LinkedHashMap<>(); + + // 优先从 data/plugins.config 加载,失败则回退到 application.yml + java.util.Set enabledIds = loadPluginConfigOrder(); + if (enabledIds == null) { + enabledIds = resolveEnabledIds(); + } + + for (Plugin plugin : allPlugins) { + try { + // 生命周期:加载 + plugin.onLoad(pluginContext); + PluginDescriptor desc = plugin.getDescriptor(); + pluginStates.put(desc.getId(), PluginState.LOADED); + + if (enabledIds == null || enabledIds.contains(desc.getId())) { + enabledPlugins.add(plugin); + pluginByIdMap.put(desc.getId(), plugin); + pluginStates.put(desc.getId(), PluginState.ACTIVE); + + String cmd = plugin.getCommandName(); + if (cmd != null && !cmd.isEmpty()) { + pluginByCommandMap.put(cmd, plugin); + } + log.info("加载插件: {}", desc); + } else { + pluginStates.put(desc.getId(), PluginState.DISABLED); + log.info("跳过已禁用插件: {}", desc); + } + } catch (Exception e) { + String pluginId = safeGetId(plugin); + pluginStates.put(pluginId, PluginState.ERROR); + log.error("插件加载失败: " + pluginId, e); + } + } + + // 按 data/plugins.config 中的顺序排列菜单 + if (enabledIds != null) { + List reordered = new ArrayList<>(); + Map pluginMap = new LinkedHashMap<>(); + for (Plugin p : enabledPlugins) { + pluginMap.put(p.getId(), p); + } + for (String id : enabledIds) { + Plugin p = pluginMap.remove(id); + if (p != null) { + reordered.add(p); + } + } + reordered.addAll(pluginMap.values()); // 兜底 + enabledPlugins = reordered; + } else { + // 回退:按 order 升序 + enabledPlugins.sort(Comparator.comparingInt(p -> p.getDescriptor().getOrder())); + } + log.info("插件加载完成: {}/{} 已激活", enabledPlugins.size(), allPlugins.size()); + } + + /** + * 获取插件配置文件路径 + */ + public Path getPluginConfigFilePath() { + return properties.getPluginsConfigFilePath(); + } + + // ==================== 查找方法 ==================== + + /** 获取所有已启用插件(只读) */ + public List getEnabledPlugins() { + return Collections.unmodifiableList(enabledPlugins); + } + + /** 按 ID 查找插件 */ + public Optional getPluginById(String id) { + return Optional.ofNullable(pluginByIdMap.get(id)); + } + + /** 按命令行命令名查找插件 */ + public Optional getPluginByCommand(String command) { + return Optional.ofNullable(pluginByCommandMap.get(command)); + } + + /** 获取插件状态 */ + public PluginState getPluginState(String pluginId) { + return pluginStates.getOrDefault(pluginId, PluginState.UNLOADED); + } + + /** 列出所有插件(含已禁用) */ + public List listAllPluginDescriptors() { + List result = new ArrayList<>(); + for (Plugin plugin : allPlugins) { + result.add(plugin.getDescriptor()); + } + return result; + } + + // ==================== 生命周期管理 ==================== + + /** 手动启用插件 */ + public boolean enablePlugin(String pluginId) { + Plugin plugin = pluginByIdMap.get(pluginId); + if (plugin == null) { + Plugin p = findPluginById(pluginId); + if (p == null) { + log.warn("未找到插件: {}", pluginId); + return false; + } + enabledPlugins.add(p); + pluginByIdMap.put(pluginId, p); + String cmd = p.getCommandName(); + if (cmd != null && !cmd.isEmpty()) { + pluginByCommandMap.put(cmd, p); + } + pluginStates.put(pluginId, PluginState.ACTIVE); + log.info("启用插件: {}", p.getDescriptor()); + } else { + pluginStates.put(pluginId, PluginState.ACTIVE); + } + return true; + } + + /** 手动禁用插件 */ + public boolean disablePlugin(String pluginId) { + Plugin plugin = pluginByIdMap.remove(pluginId); + if (plugin != null) { + enabledPlugins.remove(plugin); + String cmd = plugin.getCommandName(); + if (cmd != null && !cmd.isEmpty()) { + pluginByCommandMap.remove(cmd, plugin); + } + pluginStates.put(pluginId, PluginState.DISABLED); + log.info("禁用插件: {}", plugin.getDescriptor()); + } + return plugin != null; + } + + /** + * 重新排序插件列表,按给定 ID 顺序重排 enabledPlugins + */ + public void reorderPlugins(List orderedIds) { + Map allEnabled = new LinkedHashMap<>(); + for (Plugin p : enabledPlugins) { + allEnabled.put(p.getId(), p); + } + List reordered = new ArrayList<>(); + for (String id : orderedIds) { + Plugin p = allEnabled.remove(id); + if (p != null) { + reordered.add(p); + } + } + // 追加不在顺序列表中的(兜底) + reordered.addAll(allEnabled.values()); + enabledPlugins = reordered; + log.info("插件顺序已更新: {}", orderedIds); + } + + /** + * 将插件启用/禁用状态和顺序保存到 data/plugins.config + */ + public void savePluginConfig(List items) { + Path file = getPluginConfigFilePath(); + try { + Files.createDirectories(file.getParent()); + List orderedIds = new ArrayList<>(); + List disabledIds = new ArrayList<>(); + for (PluginManagementWindow.PluginItem item : items) { + orderedIds.add(item.getId()); + if (!item.isEnabled()) { + disabledIds.add(item.getId()); + } + } + + Properties props = new Properties(); + props.setProperty("plugins.order", String.join(",", orderedIds)); + if (!disabledIds.isEmpty()) { + props.setProperty("plugins.disabled", String.join(",", disabledIds)); + } else { + props.setProperty("plugins.disabled", ""); + } + + try (Writer w = new OutputStreamWriter( + new FileOutputStream(file.toFile()), StandardCharsets.UTF_8)) { + props.store(w, "HomeUtils Plugin Configuration"); + } + log.info("插件配置已保存到: {}", file.toAbsolutePath()); + } catch (IOException e) { + log.error("保存插件配置失败: {}", file.toAbsolutePath(), e); + throw new RuntimeException("无法保存插件配置: " + e.getMessage(), e); + } + } + + /** + * 从 data/plugins.config 加载插件配置,解析为 orderedIds(空=未配置/全部启用) + * 返回启用的插件 ID 集合,null 表示全部启用 + */ + public java.util.Set loadPluginConfigOrder() { + Path file = getPluginConfigFilePath(); + if (!Files.exists(file)) return null; + + try { + Properties props = new Properties(); + try (Reader r = new InputStreamReader( + new FileInputStream(file.toFile()), StandardCharsets.UTF_8)) { + props.load(r); + } + + String orderStr = props.getProperty("plugins.order"); + String disabledStr = props.getProperty("plugins.disabled"); + + Set disabledIds = new HashSet<>(); + if (disabledStr != null && !disabledStr.trim().isEmpty()) { + for (String s : disabledStr.split(",")) { + disabledIds.add(s.trim()); + } + } + + java.util.Set result = new java.util.LinkedHashSet<>(); + if (orderStr != null && !orderStr.trim().isEmpty()) { + for (String rawId : orderStr.split(",")) { + String id = rawId.trim(); + if (!id.isEmpty() && !disabledIds.contains(id)) { + result.add(id); + } + } + } + + // plugins.order only controls ordering. A plugin introduced by a newer + // HomeUtils version is enabled by default unless explicitly disabled. + allPlugins.stream() + .sorted(Comparator.comparingInt(plugin -> plugin.getDescriptor().getOrder())) + .map(Plugin::getId) + .filter(id -> !disabledIds.contains(id)) + .forEach(result::add); + return result; + } catch (IOException e) { + log.warn("读取插件配置文件失败: {}", file.toAbsolutePath(), e); + return null; + } + } + + // ==================== 内部方法 ==================== + + private Plugin findPluginById(String id) { + for (Plugin p : allPlugins) { + if (p.getId().equals(id)) { + return p; + } + } + return null; + } + + private String safeGetId(Plugin plugin) { + try { + return plugin.getId(); + } catch (Exception e) { + return "unknown"; + } + } + + // ==================== 启用/禁用解析 ==================== + + private Set resolveEnabledIds() { + HomeUtilsProperties.ToolsConfig toolsConfig = properties.getTools(); + if (toolsConfig == null) { + return null; + } + + if (toolsConfig.getEnabled() != null && !toolsConfig.getEnabled().isEmpty()) { + return new HashSet<>(toolsConfig.getEnabled()); + } + + if (toolsConfig.getDisabled() != null && !toolsConfig.getDisabled().isEmpty()) { + Set allIds = new HashSet<>(); + for (Plugin plugin : allPlugins) { + allIds.add(plugin.getId()); + } + allIds.removeAll(toolsConfig.getDisabled()); + return allIds; + } + + return null; + } +} diff --git a/src/main/java/com/example/homeutils/system/plugin/PluginState.java b/src/main/java/com/example/homeutils/system/plugin/PluginState.java new file mode 100644 index 0000000..04a1fae --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/PluginState.java @@ -0,0 +1,22 @@ +package com.example.homeutils.system.plugin; + +/** + * 插件生命周期状态 + */ +public enum PluginState { + + /** 未加载 */ + UNLOADED, + + /** 已加载(onLoad 完成) */ + LOADED, + + /** 已激活(已启用,可交互) */ + ACTIVE, + + /** 已禁用 */ + DISABLED, + + /** 加载/执行异常 */ + ERROR +} diff --git a/src/main/java/com/example/homeutils/system/plugin/VisualPluginFactory.java b/src/main/java/com/example/homeutils/system/plugin/VisualPluginFactory.java new file mode 100644 index 0000000..b44acbd --- /dev/null +++ b/src/main/java/com/example/homeutils/system/plugin/VisualPluginFactory.java @@ -0,0 +1,32 @@ +package com.example.homeutils.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/homeutils/system/support/AppExecutors.java b/src/main/java/com/example/homeutils/system/support/AppExecutors.java new file mode 100644 index 0000000..d83b5c1 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/AppExecutors.java @@ -0,0 +1,28 @@ +package com.example.homeutils.system.support; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +public final class AppExecutors { + + private static final ExecutorService IO = Executors.newCachedThreadPool( + namedDaemonFactory("homeutils-io-")); + + private AppExecutors() { + } + + public static ExecutorService io() { + return IO; + } + + private static ThreadFactory namedDaemonFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return runnable -> { + Thread thread = new Thread(runnable, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/main/java/com/example/homeutils/system/support/ApplicationPaths.java b/src/main/java/com/example/homeutils/system/support/ApplicationPaths.java new file mode 100644 index 0000000..c2eb9e0 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/ApplicationPaths.java @@ -0,0 +1,68 @@ +package com.example.homeutils.system.support; + +import java.nio.file.Path; +import java.util.Arrays; + +/** Resolves every HomeUtils runtime path from one stable application home. */ +public final class ApplicationPaths { + + public static final String HOME_PROPERTY = "homeutils.home"; + public static final String HOME_ENV = "HOMEUTILS_HOME"; + + private final Path home; + + private ApplicationPaths(Path home) { + this.home = home.toAbsolutePath().normalize(); + } + + public static ApplicationPaths current() { + return resolve(System.getProperty(HOME_PROPERTY), System.getenv(HOME_ENV), + System.getProperty("user.dir")); + } + + public static ApplicationPaths of(Path home) { + if (home == null) { + throw new IllegalArgumentException("Application home must not be null"); + } + return new ApplicationPaths(home); + } + + static ApplicationPaths resolve(String property, String environment, String userDirectory) { + String selected = firstNonBlank(property, environment, userDirectory); + if (selected == null) { + throw new IllegalStateException("Application home is unavailable"); + } + return new ApplicationPaths(Path.of(selected.trim())); + } + + public Path home() { + return home; + } + + public Path resolve(String first, String... more) { + return home.resolve(Path.of(first, more)).normalize(); + } + + public Path resolveConfigured(String configured, String... fallbackSegments) { + Path value; + if (configured == null || configured.isBlank()) { + if (fallbackSegments.length == 0) { + throw new IllegalArgumentException("A fallback path is required"); + } + value = Path.of(fallbackSegments[0], + Arrays.copyOfRange(fallbackSegments, 1, fallbackSegments.length)); + } else { + value = Path.of(configured.trim()); + } + return (value.isAbsolute() ? value : home.resolve(value)).normalize(); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return null; + } +} diff --git a/src/main/java/com/example/homeutils/system/support/FileOperationHelper.java b/src/main/java/com/example/homeutils/system/support/FileOperationHelper.java new file mode 100644 index 0000000..f92bac4 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/FileOperationHelper.java @@ -0,0 +1,232 @@ +package com.example.homeutils.system.support; + +import com.example.homeutils.system.config.HomeUtilsProperties; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 文件操作服务 + * 负责处理文件和目录的读取、扫描等操作 + */ +@Service +@Slf4j +public class FileOperationHelper { + + @Autowired + private HomeUtilsProperties properties; + + private static final java.nio.charset.Charset UTF8_CHARSET = java.nio.charset.StandardCharsets.UTF_8; + private static final long DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024; + private static final String LINE_SEPARATOR = System.lineSeparator(); + + // ==================== 文件读取相关方法 ==================== + + public List readLinesFromFile(String filePath) throws IOException { + return readLinesFromFile(Paths.get(filePath)); + } + + public List readLinesFromFile(Path filePath) throws IOException { + try { + return Files.readAllLines(filePath, UTF8_CHARSET); + } catch (java.nio.charset.MalformedInputException e) { + log.warn("使用UTF-8解码文件失败: {}, 尝试使用默认字符集", filePath); + try { + return Files.readAllLines(filePath); + } catch (java.nio.charset.MalformedInputException e2) { + log.warn("使用默认字符集解码文件也失败: {}, 返回空列表", filePath); + return new ArrayList<>(); + } + } + } + + public String readFileContent(Path filePath) throws IOException { + try { + List lines = Files.readAllLines(filePath, UTF8_CHARSET); + return String.join(LINE_SEPARATOR, lines); + } catch (java.nio.charset.MalformedInputException e) { + log.warn("使用UTF-8解码文件失败: {}, 尝试使用默认字符集", filePath); + try { + List lines = Files.readAllLines(filePath); + return String.join(LINE_SEPARATOR, lines); + } catch (java.nio.charset.MalformedInputException e2) { + log.warn("使用默认字符集解码文件也失败: {}, 返回空字符串", filePath); + return ""; + } + } + } + + // ==================== 文件名生成相关方法 ==================== + + public String generateFileName(String baseDirectory, String prefix, String extension) { + String timestamp = java.time.LocalDateTime.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm-ss")); + String fileName = String.format("%s_%s.%s", prefix, timestamp, extension); + return Paths.get(baseDirectory).resolve(fileName).toString(); + } + + public String generateFileName(String prefix, String extension) { + return properties.resolveExportPath(prefix, extension).toString(); + } + + public String extractProjectName(String projectPath) { + if (projectPath == null || projectPath.isEmpty()) { + return "unknown"; + } + Path path = Paths.get(projectPath); + String fileName = path.getFileName().toString(); + if (fileName.endsWith("/") || fileName.endsWith("\\")) { + fileName = fileName.substring(0, fileName.length() - 1); + } + return fileName.isEmpty() ? "unknown" : fileName; + } + + // ==================== 项目路径相关方法 ==================== + + public enum ProjectStatus { + NORMAL, + CORRUPTED + } + + public static class ProjectPathInfo { + private String path; + private ProjectStatus status; + private String corruptionReason; + /** 远程仓库 BASE_URL(可选),格式: path=BASE_URL */ + private String remoteBaseUrl; + + public ProjectPathInfo(String path, ProjectStatus status) { + this.path = path; + this.status = status; + } + + public ProjectPathInfo(String path, ProjectStatus status, String corruptionReason) { + this.path = path; + this.status = status; + this.corruptionReason = corruptionReason; + } + + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public ProjectStatus getStatus() { return status; } + public void setStatus(ProjectStatus status) { this.status = status; } + public String getCorruptionReason() { return corruptionReason; } + public void setCorruptionReason(String corruptionReason) { this.corruptionReason = corruptionReason; } + public String getRemoteBaseUrl() { return remoteBaseUrl; } + public void setRemoteBaseUrl(String remoteBaseUrl) { this.remoteBaseUrl = remoteBaseUrl; } + + public String toConfigLine() { + if (remoteBaseUrl != null && !remoteBaseUrl.isEmpty()) { + return path + "=" + remoteBaseUrl; + } + return path; + } + } + + public List getProjectPaths() throws IOException { + Path projectPathsFile = properties.getProjectPathsFilePath(); + if (!Files.exists(projectPathsFile)) { + log.warn("项目路径文件不存在: {}", projectPathsFile); + return new ArrayList<>(); + } + + return readLinesFromFile(projectPathsFile).stream() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .map(line -> { + int eqIdx = line.indexOf('='); + if (eqIdx >= 0) { + return line.substring(0, eqIdx).trim(); + } + return line; + }) + .collect(Collectors.toList()); + } + + public List getProjectPathsWithStatus() throws IOException { + Path projectPathsFile = properties.getProjectPathsFilePath(); + if (!Files.exists(projectPathsFile)) { + log.warn("项目路径文件不存在: {}", projectPathsFile); + return new ArrayList<>(); + } + + return readLinesFromFile(projectPathsFile).stream() + .map(String::trim) + .filter(line -> !line.isEmpty() && !line.startsWith("#")) + .map(line -> { + // 格式1: path=BASE_URL + // 格式2: path(纯路径) + int eqIdx = line.indexOf('='); + if (eqIdx >= 0) { + String path = line.substring(0, eqIdx).trim(); + String baseUrl = line.substring(eqIdx + 1).trim(); + ProjectPathInfo info = new ProjectPathInfo(path, ProjectStatus.NORMAL); + if (!baseUrl.isEmpty()) { + info.setRemoteBaseUrl(baseUrl); + } + return info; + } + return new ProjectPathInfo(line, ProjectStatus.NORMAL); + }) + .collect(Collectors.toList()); + } + + public void updateProjectStatus(String path, ProjectStatus status) throws IOException { + List projects = getProjectPathsWithStatus(); + for (ProjectPathInfo project : projects) { + if (project.getPath().equals(path)) { + project.setStatus(status); + break; + } + } + saveProjectPaths(projects); + } + + public void saveProjectPaths(List projects) throws IOException { + String content = projects.stream() + .map(ProjectPathInfo::toConfigLine) + .collect(Collectors.joining("\n")); + writeToFile(properties.getProjectPathsFilePath(), content); + } + + // ==================== 文件写入相关方法 ==================== + + public void writeToFile(Path path, String content) throws IOException { + Files.createDirectories(path.getParent()); + Files.write(path, content.getBytes(java.nio.charset.StandardCharsets.UTF_8), + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + log.debug("文件写入成功: {}", path); + } + + public void writeToFile(String filePath, String content) throws IOException { + writeToFile(Paths.get(filePath), content); + } + + // ==================== 文件检查相关方法 ==================== + + public boolean fileExists(String filePath) { + return Files.exists(Paths.get(filePath)); + } + + public boolean fileExists(Path path) { + return Files.exists(path); + } + + public void ensureDirectoryExists(Path path) throws IOException { + if (!Files.exists(path)) { + Files.createDirectories(path); + log.debug("目录创建成功: {}", path); + } + } + + public void ensureDirectoryExists(String path) throws IOException { + ensureDirectoryExists(Paths.get(path)); + } +} diff --git a/src/main/java/com/example/homeutils/system/support/HistoryPersistence.java b/src/main/java/com/example/homeutils/system/support/HistoryPersistence.java new file mode 100644 index 0000000..367e1f7 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/HistoryPersistence.java @@ -0,0 +1,88 @@ +package com.example.homeutils.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) { + this(name, ApplicationPaths.current()); + } + + public HistoryPersistence(String name, ApplicationPaths applicationPaths) { + Path dataDir = applicationPaths.resolve("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 void clear() { + try { + Files.deleteIfExists(filePath); + } catch (IOException e) { + System.err.println("[HistoryPersistence] 清空历史文件失败: " + e.getMessage()); + } + } + + public String getFilePath() { + return filePath.toString(); + } +} diff --git a/src/main/java/com/example/homeutils/system/support/JfxBootstrap.java b/src/main/java/com/example/homeutils/system/support/JfxBootstrap.java new file mode 100644 index 0000000..eef212a --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/JfxBootstrap.java @@ -0,0 +1,49 @@ +package com.example.homeutils.system.support; + +import javafx.application.Platform; +import javafx.embed.swing.JFXPanel; + +import java.util.concurrent.CountDownLatch; + +/** + * 共享的 JavaFX 启动器 —— 通过 {@link JFXPanel} 初始化工具包 + *

+ * 与 {@code Application.launch()} 不同,本方式不进入阻塞事件循环, + * 不会在 Windows 上干扰 {@code System.in},避免关闭窗口后控制台无法输入。 + *

+ * JavaFX 工具包全局仅初始化一次,所有窗口复用。 + */ +public final class JfxBootstrap { + + private static final CountDownLatch READY = new CountDownLatch(1); + private static volatile boolean failed; + + private JfxBootstrap() {} + + static { + Thread t = new Thread(() -> { + try { + // 窗口关闭时不退出工具包,允许反复打开设置窗口 + Platform.setImplicitExit(false); + // JFXPanel 构造即触发 JavaFX 工具包初始化,不依赖 Application.launch() + new JFXPanel(); + READY.countDown(); + } catch (Exception e) { + failed = true; + READY.countDown(); + } + }, "JavaFX-Init"); + t.setDaemon(true); + t.start(); + } + + /** 阻塞等待 JavaFX 运行时就绪 */ + public static void await() throws InterruptedException { + READY.await(); + } + + /** JavaFX 是否初始化失败 */ + public static boolean isFailed() { + return failed; + } +} diff --git a/src/main/java/com/example/homeutils/system/support/OutputHelper.java b/src/main/java/com/example/homeutils/system/support/OutputHelper.java new file mode 100644 index 0000000..2028964 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/OutputHelper.java @@ -0,0 +1,144 @@ +package com.example.homeutils.system.support; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.example.homeutils.system.config.HomeUtilsProperties; +import com.opencsv.CSVWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.util.Collections; +import java.util.List; + +/** + * 输出服务 + * 负责将数据输出到CSV、Excel等格式的文件 + */ +@Service +@Slf4j +public class OutputHelper { + + @Autowired + private FileOperationHelper fileOperationHelper; + + @Autowired + private HomeUtilsProperties properties; + + // ==================== CSV输出相关方法 ==================== + + public void writeRecordsToCsv(List records, String outputPath, String... headers) throws IOException { + Path filePath; + if (outputPath.startsWith(properties.getExportBasePath().toString())) { + filePath = Paths.get(outputPath); + } else { + filePath = properties.resolveExportPath(outputPath); + } + fileOperationHelper.ensureDirectoryExists(filePath.getParent()); + + try (Writer fileWriter = Files.newBufferedWriter(filePath, StandardCharsets.UTF_8); + CSVWriter writer = new CSVWriter(fileWriter)) { + if (headers.length > 0) { + writer.writeNext(headers); + } + writer.writeAll(records); + writer.flush(); + } + + log.info("CSV文件写入成功: {}", filePath); + } + + public void writeRecordsToCsv(List records, String outputPath, List headers) throws IOException { + writeRecordsToCsv(records, outputPath, headers.toArray(new String[0])); + } + + public void writeSingleRecordToCsv(String[] record, String outputPath, String... headers) throws IOException { + writeRecordsToCsv(Collections.singletonList(record), outputPath, headers); + } + + public void appendToCsv(String[] record, String outputPath, String... headers) throws IOException { + Path filePath = properties.resolveExportPath(outputPath); + fileOperationHelper.ensureDirectoryExists(filePath.getParent()); + + boolean fileExists = fileOperationHelper.fileExists(filePath); + boolean shouldWriteHeaders = !fileExists && headers.length > 0; + + try (Writer fileWriter = new OutputStreamWriter( + Files.newOutputStream(filePath, StandardOpenOption.CREATE, StandardOpenOption.APPEND), + StandardCharsets.UTF_8); + CSVWriter writer = new CSVWriter(fileWriter)) { + if (shouldWriteHeaders) { + writer.writeNext(headers); + } + writer.writeNext(record); + writer.flush(); + } + + log.debug("记录追加到CSV文件成功: {}", filePath); + } + + // ==================== Excel输出相关方法 ==================== + + public void writeToExcel(List data, String outputPath, Class modelClass) throws IOException { + Path filePath = properties.resolveExportPath(outputPath); + fileOperationHelper.ensureDirectoryExists(filePath.getParent()); + EasyExcel.write(filePath.toFile(), modelClass).sheet().doWrite(data); + log.info("Excel文件写入成功: {}", filePath); + } + + public void writeToExcel(List data, String outputPath, String sheetName, Class modelClass) throws IOException { + Path filePath = properties.resolveExportPath(outputPath); + fileOperationHelper.ensureDirectoryExists(filePath.getParent()); + EasyExcel.write(filePath.toFile(), modelClass).sheet(sheetName).doWrite(data); + log.info("Excel文件写入成功: {}", filePath); + } + + public void writeToExcelWithMultipleSheets(List> sheetsData, String outputPath) throws IOException { + Path filePath = properties.resolveExportPath(outputPath); + fileOperationHelper.ensureDirectoryExists(filePath.getParent()); + + try (ExcelWriter excelWriter = EasyExcel.write(filePath.toFile()).build()) { + for (ExcelSheetData sheetData : sheetsData) { + WriteSheet writeSheet = EasyExcel.writerSheet(sheetData.getSheetName()) + .head(sheetData.getModelClass()) + .build(); + excelWriter.write(sheetData.getData(), writeSheet); + } + } + + log.info("多Sheet Excel文件写入成功: {}", filePath); + } + + // ==================== 文件名生成相关方法 ==================== + + public String generateOutputFileName(String prefix, String extension) { + return fileOperationHelper.generateFileName(prefix, extension); + } + + // ==================== 内部数据类 ==================== + + public static class ExcelSheetData { + private final List data; + private final String sheetName; + private final Class modelClass; + + public ExcelSheetData(List data, String sheetName, Class modelClass) { + this.data = data; + this.sheetName = sheetName; + this.modelClass = modelClass; + } + + public List getData() { return data; } + public String getSheetName() { return sheetName; } + public Class getModelClass() { return modelClass; } + } +} diff --git a/src/main/java/com/example/homeutils/system/support/SystemOpenHelper.java b/src/main/java/com/example/homeutils/system/support/SystemOpenHelper.java new file mode 100644 index 0000000..5dc9605 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/SystemOpenHelper.java @@ -0,0 +1,191 @@ +package com.example.homeutils.system.support; + +import java.awt.Desktop; +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Locale; + +public final class SystemOpenHelper { + + private static final OperatingSystem CURRENT_OS = detectOs(); + + private SystemOpenHelper() { + } + + public static void initialize() { + currentOs(); + } + + public static OperatingSystem currentOs() { + return CURRENT_OS; + } + + public static boolean isWindows() { + return CURRENT_OS == OperatingSystem.WINDOWS; + } + + public static boolean isMac() { + return CURRENT_OS == OperatingSystem.MAC; + } + + public static void openFile(String path) throws IOException { + if (path == null || path.trim().isEmpty()) { + return; + } + openFile(Path.of(path)); + } + + public static void openFile(Path path) throws IOException { + if (path == null) { + return; + } + File file = path.toFile(); + if (!file.exists()) { + return; + } + if (tryDesktopOpen(file)) { + return; + } + runOpenFallback(file); + } + + public static void editFile(String path) throws IOException { + if (path == null || path.trim().isEmpty()) { + return; + } + editFile(Path.of(path)); + } + + public static void editFile(Path path) throws IOException { + if (path == null) { + return; + } + File file = path.toFile(); + if (!file.exists()) { + return; + } + if (tryDesktopEdit(file)) { + return; + } + switch (CURRENT_OS) { + case WINDOWS: + start("notepad.exe", file.getAbsolutePath()); + break; + case MAC: + start("open", "-e", file.getAbsolutePath()); + break; + case LINUX: + case OTHER: + default: + runOpenFallback(file); + break; + } + } + + public static void revealInFileManager(String path) throws IOException { + if (path == null || path.trim().isEmpty()) { + return; + } + revealInFileManager(Path.of(path)); + } + + public static void revealInFileManager(Path path) throws IOException { + if (path == null) { + return; + } + File target = path.toFile(); + File parent = target.isDirectory() ? target : target.getParentFile(); + if (parent == null || !parent.exists()) { + return; + } + switch (CURRENT_OS) { + case WINDOWS: + if (target.exists() && target.isFile()) { + start("explorer.exe", "/select," + target.getAbsolutePath()); + } else { + start("explorer.exe", parent.getAbsolutePath()); + } + break; + case MAC: + if (target.exists()) { + start("open", "-R", target.getAbsolutePath()); + } else { + start("open", parent.getAbsolutePath()); + } + break; + case LINUX: + case OTHER: + default: + if (!tryDesktopOpen(parent)) { + start("xdg-open", parent.getAbsolutePath()); + } + break; + } + } + + private static OperatingSystem detectOs() { + String osName = System.getProperty("os.name", "").toLowerCase(Locale.ROOT); + if (osName.contains("win")) { + return OperatingSystem.WINDOWS; + } + if (osName.contains("mac") || osName.contains("darwin")) { + return OperatingSystem.MAC; + } + if (osName.contains("nux") || osName.contains("nix") || osName.contains("aix")) { + return OperatingSystem.LINUX; + } + return OperatingSystem.OTHER; + } + + private static boolean tryDesktopOpen(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + Desktop.getDesktop().open(file); + return true; + } + } catch (IOException | RuntimeException ignored) { + return false; + } + return false; + } + + private static boolean tryDesktopEdit(File file) { + try { + if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.EDIT)) { + Desktop.getDesktop().edit(file); + return true; + } + } catch (IOException | RuntimeException ignored) { + return false; + } + return false; + } + + private static void runOpenFallback(File file) throws IOException { + switch (CURRENT_OS) { + case WINDOWS: + start("cmd", "/c", "start", "", file.getAbsolutePath()); + break; + case MAC: + start("open", file.getAbsolutePath()); + break; + case LINUX: + case OTHER: + default: + start("xdg-open", file.getAbsolutePath()); + break; + } + } + + private static void start(String... command) throws IOException { + new ProcessBuilder(command).start(); + } + + public enum OperatingSystem { + WINDOWS, + MAC, + LINUX, + OTHER + } +} diff --git a/src/main/java/com/example/homeutils/system/support/WinConsole.java b/src/main/java/com/example/homeutils/system/support/WinConsole.java new file mode 100644 index 0000000..baf1ef0 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/support/WinConsole.java @@ -0,0 +1,100 @@ +package com.example.homeutils.system.support; + +import com.sun.jna.platform.win32.Kernel32; + +import java.io.FileDescriptor; +import java.io.FileOutputStream; +import java.io.PrintStream; +import java.io.UnsupportedEncodingException; +import java.nio.charset.Charset; +import java.util.Locale; + +/** + * Windows 控制台工具:确保 System.out / System.err 与 logback 使用相同编码。 + *

+ * 编码跟随 JVM 的 file.encoding(logback.xml 不再硬编码 charset): + * - exe 模式: file.encoding=UTF-8 → 设置控制台代码页 65001 + * - IDEA: file.encoding=GBK → 控制台代码页 936,提示用户可改为 UTF-8 + *

+ * 在 main() 最开始调用,早于 Spring / logback 初始化。 + */ +public final class WinConsole { + + private WinConsole() {} + + /** + * 确保控制台代码页和 System.out/err 编码与 JVM file.encoding 一致。 + */ + public static void ensureUtf8() { + if (!System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win")) { + return; + } + + Charset jvmCharset = Charset.defaultCharset(); + String jvmEnc = jvmCharset.name(); + boolean isUtf8 = "UTF-8".equalsIgnoreCase(jvmEnc) || "UTF8".equalsIgnoreCase(jvmEnc); + + // 检测 IDEA:IDEA 传递 idea_rt.jar 作为 javaagent + boolean inIde = isRunningInIde(); + + // 1) 设置控制台代码页 + try { + Kernel32 kernel = Kernel32.INSTANCE; + if (isUtf8) { + kernel.SetConsoleOutputCP(65001); // UTF-8 + kernel.SetConsoleCP(65001); + } else { + // 系统编码通常是 GBK → 代码页 936 + kernel.SetConsoleOutputCP(936); + kernel.SetConsoleCP(936); + } + } catch (Throwable ignored) { + // JNA 不可用时忽略 + } + + // 2) 重定向 System.out / System.err,使用 JVM 默认编码 + // 同时 logback.xml 不指定 charset,也跟随 file.encoding + // 保证两者一致,不会出现双重编码乱码 + try { + System.setOut(new PrintStream( + new FileOutputStream(FileDescriptor.out), true, jvmEnc)); + System.setErr(new PrintStream( + new FileOutputStream(FileDescriptor.err), true, jvmEnc)); + } catch (UnsupportedEncodingException ignored) { + // 不会发生 + } + + // 3) IDEA 环境下提示用户可改为 UTF-8 以获得更好体验 + if (inIde && !isUtf8) { + String msg = String.format( + "%n[提示] 检测到 IDEA 内置控制台 (file.encoding=%s),中文显示正常。%n" + + "[提示] 如需统一为 UTF-8,请在 Run Configuration 的 VM options 中添加: %n" + + "[提示] -Dfile.encoding=UTF-8%n%n", jvmEnc); + // 注意:不能 close wrapping FileDescriptor.out 的流,否则会关掉 stdout + try { + FileOutputStream fos = new FileOutputStream(FileDescriptor.out); + fos.write(msg.getBytes(jvmEnc)); + fos.flush(); + // deliberately NOT closing fos — it wraps stdout + } catch (Exception ignored) { + } + } + } + + /** + * 检测是否在 IDE 内置控制台中运行。 + * IDEA 会传递 idea_rt.jar 作为 javaagent 参数。 + */ + private static boolean isRunningInIde() { + try { + for (String arg : java.lang.management.ManagementFactory.getRuntimeMXBean() + .getInputArguments()) { + if (arg.contains("idea_rt.jar") || arg.contains("jetbrains")) { + return true; + } + } + } catch (Throwable ignored) { + } + return false; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/DesignTokens.java b/src/main/java/com/example/homeutils/system/ui/DesignTokens.java new file mode 100644 index 0000000..18914a5 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/DesignTokens.java @@ -0,0 +1,40 @@ +package com.example.homeutils.system.ui; + +import javafx.scene.text.Font; + +import java.util.List; + +/** + * Shared dimensions and typography choices for the application shell. + */ +public final class DesignTokens { + + public static final int FONT_PAGE_TITLE = 20; + public static final int FONT_SECTION_TITLE = 15; + public static final int FONT_BODY = 14; + public static final int FONT_SECONDARY = 13; + + public static final int ROW_HEIGHT = 40; + public static final int CONTROL_HEIGHT = 36; + public static final int NAV_EXPANDED_WIDTH = 214; + public static final int NAV_COLLAPSED_WIDTH = 56; + public static final int HEADER_HEIGHT = 48; + + private DesignTokens() { + } + + static String firstAvailable(List preferred, List installed) { + return preferred.stream().filter(installed::contains).findFirst().orElse("System"); + } + + public static String uiFontFamily() { + return firstAvailable(List.of("Segoe UI Variable Text", "Segoe UI", "Microsoft YaHei UI"), + Font.getFamilies()); + } + + public static String monoFontFamily() { + String preferredFont = firstAvailable( + List.of("JetBrains Mono", "Cascadia Mono", "Consolas"), Font.getFamilies()); + return "System".equals(preferredFont) ? "Monospaced" : preferredFont; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/EmptyStatePane.java b/src/main/java/com/example/homeutils/system/ui/EmptyStatePane.java new file mode 100644 index 0000000..21934e1 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/EmptyStatePane.java @@ -0,0 +1,56 @@ +package com.example.homeutils.system.ui; + +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; + +import java.util.Objects; + +public final class EmptyStatePane extends VBox { + + public record Content(String title, String description, String actionLabel) { + public Content { + title = Objects.requireNonNull(title, "title").trim(); + description = Objects.requireNonNull(description, "description").trim(); + actionLabel = Objects.requireNonNull(actionLabel, "actionLabel").trim(); + if (title.isEmpty()) { + throw new IllegalArgumentException("title must not be blank"); + } + } + } + + public EmptyStatePane(Content content, Runnable action, int fontSize) { + super(Theme.S2); + Objects.requireNonNull(content, "content"); + setAlignment(Pos.CENTER); + getStyleClass().add("empty-state"); + setAccessibleText(content.description().isEmpty() + ? content.title() + : content.title() + "。" + content.description()); + setFocusTraversable(false); + + Label title = new Label(content.title()); + title.setFont(Theme.heading(fontSize)); + title.getStyleClass().add("empty-state-title"); + + Label description = new Label(content.description()); + description.setFont(Theme.base(fontSize - 1)); + description.setWrapText(true); + description.getStyleClass().add("empty-state-description"); + getChildren().addAll(title, description); + + if (!content.actionLabel().isEmpty()) { + if (action == null) { + throw new IllegalArgumentException( + "action is required when actionLabel is present"); + } + Button button = StyledButton.secondary( + content.actionLabel(), Theme.base(fontSize - 1)); + button.setAccessibleText(content.actionLabel()); + button.setFocusTraversable(true); + button.setOnAction(event -> action.run()); + getChildren().add(button); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/MainWindow.java b/src/main/java/com/example/homeutils/system/ui/MainWindow.java new file mode 100644 index 0000000..6882fc8 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/MainWindow.java @@ -0,0 +1,331 @@ +package com.example.homeutils.system.ui; + +import com.example.homeutils.system.config.HomeUtilsProperties; +import com.example.homeutils.system.ui.icon.AppIcon; +import com.example.homeutils.system.ui.icon.IconFactory; +import com.example.homeutils.system.ui.shell.NavigationRail; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Label; +import javafx.scene.image.Image; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.TextAlignment; +import javafx.stage.Stage; +import org.kordamp.ikonli.javafx.FontIcon; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** Main application window and shell-level plugin content host. */ +public class MainWindow { + private final Stage stage; + private final HomeUtilsProperties properties; + private final Map embeddedCache = new LinkedHashMap<>(); + + private List navItems; + private NavigationRail navigationRail; + private StackPane contentArea; + private NavItem activeNavItem; + private String activePluginId; + private EmbeddedEntry currentEmbedded; + + private FontIcon toolbarChevron; + private Label toolbarTitle; + + public MainWindow(List navItems, HomeUtilsProperties properties, Runnable openSettings) { + this.navItems = new ArrayList<>(navItems); + this.properties = properties; + this.stage = new Stage(); + buildUI(Objects.requireNonNull(openSettings, "openSettings must not be null")); + } + + public void refreshNavItems(List newItems) { + embeddedCache.clear(); + navItems = new ArrayList<>(newItems); + navigationRail.setItems(navItems); + showWelcomePage(); + } + + public void show() { + stage.show(); + } + + public void showAndWait() { + stage.showAndWait(); + } + + private void setAppIcon() { + try (InputStream input = getClass().getClassLoader().getResourceAsStream("app_icon.png")) { + if (input != null) { + stage.getIcons().add(new Image(input)); + } + } catch (Exception ignored) { + // A missing window icon must not prevent the application from starting. + } + } + + private void buildUI(Runnable openSettings) { + setAppIcon(); + stage.setTitle("HomeUtils — 开发工作集"); + stage.setWidth(1280); + stage.setHeight(800); + stage.setMinWidth(1100); + stage.setMinHeight(680); + stage.setOnCloseRequest(event -> embeddedCache.clear()); + + BorderPane root = new BorderPane(); + root.setTop(buildUnifiedHeader()); + + navigationRail = new NavigationRail(navItems, this::navigateTo, openSettings); + contentArea = new StackPane(); + contentArea.getStyleClass().add(Theme.CSS_CONTENT_AREA); + HBox.setHgrow(contentArea, Priority.ALWAYS); + + HBox shell = new HBox(navigationRail, contentArea); + root.setCenter(shell); + + Scene scene = new Scene(root); + Theme.applyCustomCss(scene); + stage.setScene(scene); + showWelcomePage(); + } + + private HBox buildUnifiedHeader() { + HBox bar = new HBox(Theme.S1); + bar.setId("app-header"); + bar.setAlignment(Pos.CENTER_LEFT); + bar.setPadding(new Insets(0, Theme.S5, 0, Theme.S4)); + bar.setMinHeight(DesignTokens.HEADER_HEIGHT); + bar.setPrefHeight(DesignTokens.HEADER_HEIGHT); + bar.setMaxHeight(DesignTokens.HEADER_HEIGHT); + bar.getStyleClass().add(Theme.CSS_HEADER_BAR); + + FontIcon brandIcon = IconFactory.create(AppIcon.APP, 18); + StackPane brandIconContainer = new StackPane(brandIcon); + brandIconContainer.setId("app-brand-icon-container"); + brandIconContainer.getStyleClass().add("app-brand-icon"); + brandIconContainer.setMinSize(32, 32); + brandIconContainer.setPrefSize(32, 32); + brandIconContainer.setMaxSize(32, 32); + brandIconContainer.setAccessibleText("HomeUtils 应用图标"); + brandIconContainer.setFocusTraversable(false); + + Label brandText = new Label("HomeUtils"); + brandText.setFont(Theme.sectionTitle()); + HBox brand = new HBox(Theme.S2, brandIconContainer, brandText); + brand.setAlignment(Pos.CENTER_LEFT); + + toolbarChevron = IconFactory.create(AppIcon.CHEVRON_RIGHT, 14); + toolbarChevron.setVisible(false); + toolbarChevron.setManaged(false); + toolbarTitle = new Label(); + toolbarTitle.setFont(Theme.pageTitle()); + toolbarTitle.getStyleClass().add(Theme.CSS_PAGE_TITLE); + toolbarTitle.setVisible(false); + toolbarTitle.setManaged(false); + + Region spacerRight = new Region(); + HBox.setHgrow(spacerRight, Priority.ALWAYS); + + bar.getChildren().addAll(brand, toolbarChevron, toolbarTitle, spacerRight); + return bar; + } + + private void navigateTo(NavItem item) { + activeNavItem = item; + if (item.action != null) { + item.action.run(); + } + if (!showEmbeddedFor(item)) { + navigationRail.clearSelection(); + showWelcomePage(); + } + } + + private void showPluginToolbar(String title) { + toolbarTitle.setText(title); + toolbarChevron.setVisible(true); + toolbarChevron.setManaged(true); + toolbarTitle.setVisible(true); + toolbarTitle.setManaged(true); + } + + private void hidePluginToolbar() { + toolbarChevron.setVisible(false); + toolbarChevron.setManaged(false); + toolbarTitle.setVisible(false); + toolbarTitle.setManaged(false); + } + + private void showWelcomePage() { + VBox welcome = new VBox(20); + welcome.setAlignment(Pos.CENTER); + welcome.setPadding(new Insets(60)); + welcome.getStyleClass().add(Theme.CSS_WELCOME_PAGE); + + FontIcon welcomeIcon = IconFactory.create(AppIcon.APP, 64); + Label welcomeHeading = new Label("HomeUtils 开发工作集"); + welcomeHeading.setFont(Theme.pageTitle()); + welcomeHeading.getStyleClass().add(Theme.CSS_WELCOME_HEADING); + + Label welcomeDescription = new Label( + "一站式开发工具平台 · 文本对比 · 代码生成 · Git 同步\n" + + "API 文档导出 · SQL 导出 · 关键词扫描 · 系统设置"); + welcomeDescription.setFont(Theme.body()); + welcomeDescription.getStyleClass().add(Theme.CSS_WELCOME_DESC); + welcomeDescription.setTextAlignment(TextAlignment.CENTER); + welcomeDescription.setMaxWidth(560); + + welcome.getChildren().addAll(welcomeIcon, welcomeHeading, welcomeDescription); + contentArea.getChildren().setAll(welcome); + hidePluginToolbar(); + activeNavItem = null; + activePluginId = null; + currentEmbedded = null; + } + + private boolean showEmbeddedFor(NavItem item) { + if (item.pluginId == null) { + hidePluginToolbar(); + return false; + } + EmbeddedEntry entry = embeddedCache.get(item.pluginId); + if (entry != null) { + switchToEmbedded(item.pluginId, entry); + return true; + } + return false; + } + + public void embedPlugin(String pluginId, String ignoredIcon, String name, Parent content) { + embeddedCache.remove(pluginId); + EmbeddedEntry entry = new EmbeddedEntry(content, name); + embeddedCache.put(pluginId, entry); + switchToEmbedded(pluginId, entry); + } + + private void switchToEmbedded(String pluginId, EmbeddedEntry entry) { + if ("__settings__".equals(pluginId)) { + navigationRail.selectSettings(); + } + if (currentEmbedded != null && currentEmbedded != entry) { + contentArea.getChildren().remove(currentEmbedded.wrapper); + } + activePluginId = pluginId; + currentEmbedded = entry; + + if (entry.wrapper == null) { + entry.wrapper = new BorderPane(); + entry.wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA); + entry.wrapper.setCenter(entry.content); + } + if (!contentArea.getChildren().contains(entry.wrapper)) { + contentArea.getChildren().setAll(entry.wrapper); + } + showPluginToolbar(entry.title); + } + + public void closePlugin(String pluginId) { + EmbeddedEntry entry = embeddedCache.remove(pluginId); + if (entry == null) { + return; + } + if (entry.wrapper != null) { + contentArea.getChildren().remove(entry.wrapper); + } + if (currentEmbedded == entry) { + currentEmbedded = null; + activePluginId = null; + showWelcomePage(); + } + } + + public void embedRaw(String icon, String title, Parent content) { + if (currentEmbedded != null && currentEmbedded.wrapper != null) { + contentArea.getChildren().remove(currentEmbedded.wrapper); + } + currentEmbedded = null; + activePluginId = null; + + BorderPane wrapper = new BorderPane(); + wrapper.getStyleClass().add(Theme.CSS_CONTENT_AREA); + wrapper.setCenter(content); + contentArea.getChildren().setAll(wrapper); + showPluginToolbar(title); + } + + public void refreshTheme() { + Theme.applyCustomCss(stage.getScene()); + } + + private static final class EmbeddedEntry { + private final Parent content; + private final String title; + private BorderPane wrapper; + + private EmbeddedEntry(Parent content, String title) { + this.content = content; + this.title = title; + } + } + + public static class NavItem { + public final AppIcon icon; + public final String name; + public final String description; + public final String pluginId; + public final Runnable action; + + public NavItem(AppIcon icon, String name, String description, Runnable action) { + this(icon, name, description, action, null); + } + + public NavItem(AppIcon icon, String name, String description, Runnable action, String pluginId) { + this.icon = icon; + this.name = name; + this.description = description; + this.action = action; + this.pluginId = pluginId; + } + + /** Compatibility bridge while plugin descriptors still expose their legacy icon text. */ + public NavItem(String ignoredIcon, String name, String description, Runnable action) { + this(iconForPlugin(null), name, description, action, null); + } + + /** Compatibility bridge while plugin descriptors still expose their legacy icon text. */ + public NavItem(String ignoredIcon, String name, String description, Runnable action, String pluginId) { + this(iconForPlugin(pluginId), name, description, action, pluginId); + } + + private static AppIcon iconForPlugin(String pluginId) { + if (pluginId == null) { + return AppIcon.APP; + } + return switch (pluginId) { + case "scan" -> AppIcon.SCANNER; + case "diff" -> AppIcon.DIFF; + case "api-doc" -> AppIcon.API_DOC; + case "ai-unit-test" -> AppIcon.AI_TEST; + case "git-sync" -> AppIcon.GIT_SYNC; + case "git-review" -> AppIcon.GIT_REVIEW; + case "mybatisexport" -> AppIcon.SQL_EXPORT; + case "mybatisgenerator" -> AppIcon.MYBATIS_GENERATOR; + case "wechat-ai" -> AppIcon.CHAT; + case "__settings__" -> AppIcon.SETTINGS; + default -> AppIcon.APP; + }; + } + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/StyledButton.java b/src/main/java/com/example/homeutils/system/ui/StyledButton.java new file mode 100644 index 0000000..0002c4d --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/StyledButton.java @@ -0,0 +1,53 @@ +package com.example.homeutils.system.ui; + +import javafx.scene.Cursor; +import javafx.scene.control.Button; +import javafx.scene.text.Font; + +/** + * 符合设计系统的按钮工厂 —— 使用 CSS 类而非内联样式 + */ +public final class StyledButton { + + private StyledButton() {} + + /** 主要操作按钮 */ + public static Button primary(String text, Font font) { + Button btn = new Button(text); + btn.setFont(font); + btn.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + return btn; + } + + /** 次要操作按钮(AtlantaFX 默认样式即可) */ + public static Button secondary(String text, Font font) { + Button btn = new Button(text); + btn.setFont(font); + btn.setCursor(Cursor.HAND); + return btn; + } + + /** 危险操作按钮 */ + public static Button danger(String text, Font font) { + Button btn = new Button(text); + btn.setFont(font); + btn.getStyleClass().add(Theme.CSS_BTN_DANGER); + return btn; + } + + /** 小号按钮 */ + public static Button small(String text, Font font) { + Button btn = new Button(text); + btn.setFont(font); + btn.getStyleClass().add(Theme.CSS_BTN_SMALL); + return btn; + } + + /** 成功按钮 */ + public static Button success(String text, Font font) { + Button btn = new Button(text); + btn.setFont(font); + btn.getStyleClass().add(Theme.CSS_BTN_SUCCESS); + return btn; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/Theme.java b/src/main/java/com/example/homeutils/system/ui/Theme.java new file mode 100644 index 0000000..392f7aa --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/Theme.java @@ -0,0 +1,224 @@ +package com.example.homeutils.system.ui; + +import atlantafx.base.theme.PrimerLight; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.scene.paint.Color; +import javafx.scene.text.Font; +import javafx.scene.text.FontWeight; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 工集 UI 设计系统 —— AtlantaFX 亮色主题 + *

+ * 仅支持亮色主题,CSS 样式类定义在 resources/css/light-theme.css 中。 + * 使用时通过 {@code getStyleClass().add("class-name")} 引用,而非 setStyle()。 + */ +public final class Theme { + + private Theme() {} + + private static volatile String currentCustomCss = "css/light-theme.css"; + + /** 缓存已加载的 CSS data: URI,避免重复读取 */ + private static final Map cssFileCache = new ConcurrentHashMap<>(); + + /** + * 初始化全局主题(仅亮色)。 + * 必须在 JavaFX Application Thread 上调用,且在任何 Scene 创建之前。 + */ + public static void initTheme() { + String atlantaTheme = new PrimerLight().getUserAgentStylesheet(); + Application.setUserAgentStylesheet(atlantaTheme); + } + + /** + * 将自定义 CSS 样式表添加到 Scene。 + *

+ * 兼容 Spring Boot fat JAR:使用 data: URI 内联 CSS,不依赖文件系统 URL。 + */ + public static void applyCustomCss(Scene scene) { + String dataUri = cssFileCache.computeIfAbsent(currentCustomCss, Theme::loadCssAsDataUri); + if (dataUri == null) { + System.err.println("[Theme] 警告: 找不到 CSS 资源 " + currentCustomCss + ",将使用默认样式"); + return; + } + scene.getStylesheets().removeIf(s -> s.contains("light-theme.css")); + scene.getStylesheets().add(dataUri); + } + + /** + * 从 classpath 读取 CSS 文件,转成 data: URI(Base64),JavaFX 原生支持。 + */ + private static String loadCssAsDataUri(String classpath) { + try (InputStream in = Theme.class.getClassLoader().getResourceAsStream(classpath)) { + if (in == null) { + System.err.println("[Theme] 错误: classpath 中找不到 " + classpath); + return null; + } + String css = new String(in.readAllBytes(), StandardCharsets.UTF_8); + String b64 = Base64.getEncoder().encodeToString(css.getBytes(StandardCharsets.UTF_8)); + return "data:text/css;base64," + b64; + } catch (Exception e) { + System.err.println("[Theme] 错误: 读取 CSS 失败 " + classpath + " - " + e.getMessage()); + return null; + } + } + + // ==================== 色彩系统(供代码中动态着色使用) ==================== + + public static final Color PRIMARY = Color.web("#2563eb"); + public static final Color PRIMARY_HOVER = Color.web("#1d4ed8"); + public static final Color PRIMARY_LIGHT = Color.web("#dbeafe"); + public static final Color SUCCESS = Color.web("#10b981"); + public static final Color SUCCESS_HOVER = Color.web("#059669"); + public static final Color WARNING = Color.web("#f59e0b"); + public static final Color DANGER = Color.web("#ef4444"); + public static final Color DANGER_HOVER = Color.web("#dc2626"); + + public static final Color TEXT_PRIMARY = Color.web("#1e293b"); + public static final Color TEXT_SECONDARY = Color.web("#64748b"); + public static final Color TEXT_MUTED = Color.web("#94a3b8"); + public static final Color BG_BODY = Color.web("#f8fafc"); + public static final Color BG_SURFACE = Color.web("#ffffff"); + public static final Color BG_CARD = Color.web("#f1f5f9"); + public static final Color BORDER = Color.web("#e2e8f0"); + + public static final Color NAV_BG = BG_SURFACE; + public static final Color NAV_TEXT = TEXT_PRIMARY; + + // ==================== CSS 类名常量(统一引用,避免魔法字符串) ==================== + + public static final String CSS_NAV_BAR = "nav-bar"; + public static final String CSS_NAV_BRAND = "nav-brand"; + public static final String CSS_NAV_SEP = "nav-separator"; + public static final String CSS_NAV_LIST = "nav-list"; + public static final String CSS_NAV_CELL = "nav-cell"; + public static final String CSS_NAV_CELL_ACTIVE = "nav-cell-active"; + public static final String CSS_NAV_INDICATOR = "nav-indicator"; + /** @deprecated The ripple has no visual style; retained until the navigation shell is migrated. */ + @Deprecated public static final String CSS_NAV_RIPPLE = "nav-ripple"; + public static final String CSS_NAV_VERSION = "nav-version"; + + public static final String CSS_HEADER_BAR = "header-bar"; + public static final String CSS_CONTENT_AREA = "content-area"; + + public static final String CSS_CARD = "card"; + + public static final String CSS_BTN_PRIMARY = "btn-primary"; + public static final String CSS_BTN_DANGER = "btn-danger"; + public static final String CSS_BTN_SUCCESS = "btn-success"; + public static final String CSS_BTN_WARNING = "btn-warning"; + public static final String CSS_BTN_SMALL = "btn-small"; + + public static final String CSS_TEXT_FIELD = "text-field-default"; + public static final String CSS_TEXT_AREA = "text-area-default"; + public static final String CSS_LOG_AREA = "log-area"; + + public static final String CSS_STATUS_BAR = "status-bar"; + + public static final String CSS_DB_INFO_BAR = "db-info-bar"; + public static final String CSS_DB_INFO_BOX = "db-info-box"; + + public static final String CSS_TABLE = "table-default"; + + public static final String CSS_PAGE_TITLE = "page-title"; + public static final String CSS_SECTION_TITLE = "section-title"; + public static final String CSS_BODY_TEXT = "body-text"; + public static final String CSS_SECONDARY_TEXT = "secondary-text"; + public static final String CSS_APP_ICON = "app-icon"; + public static final String CSS_CONTROL_DEFAULT = "control-default"; + public static final String CSS_HINT_TEXT = "hint-text"; + public static final String CSS_MUTED_TEXT = "muted-text"; + public static final String CSS_DANGER_TEXT = "danger-text"; + public static final String CSS_WARNING_TEXT = "warning-text"; + + public static final String CSS_WELCOME_PAGE = "welcome-page"; + public static final String CSS_WELCOME_HEADING = "welcome-heading"; + public static final String CSS_WELCOME_DESC = "welcome-desc"; + + public static final String CSS_DIVIDER = "divider"; + public static final String CSS_TOGGLE_BTN = "toggle-btn"; + public static final String CSS_COMBO = "combo-default"; + public static final String CSS_PROJECT_TREE = "project-tree"; + public static final String CSS_MONO_TEXT = "mono-text"; + + // ==================== 旧版 STYLE_* 别名(向后兼容) ==================== + public static final String STYLE_TEXT_FIELD = CSS_TEXT_FIELD; + public static final String STYLE_TEXT_AREA = CSS_TEXT_AREA; + public static final String STYLE_LOG_AREA = CSS_LOG_AREA; + public static final String STYLE_CARD = CSS_CARD; + public static final String STYLE_COMBO = CSS_COMBO; + + // ==================== 字体系统(保留,各窗口通过代码动态设置) ==================== + + public static Font pageTitle() { return Font.font(DesignTokens.uiFontFamily(), FontWeight.SEMI_BOLD, + DesignTokens.FONT_PAGE_TITLE); } + public static Font sectionTitle() { return Font.font(DesignTokens.uiFontFamily(), FontWeight.SEMI_BOLD, + DesignTokens.FONT_SECTION_TITLE); } + public static Font body() { return Font.font(DesignTokens.uiFontFamily(), DesignTokens.FONT_BODY); } + public static Font secondary() { return Font.font(DesignTokens.uiFontFamily(), DesignTokens.FONT_SECONDARY); } + public static Font tableText() { return body(); } + public static Font mono() { return Font.font(DesignTokens.monoFontFamily(), DesignTokens.FONT_BODY); } + + /** @deprecated Migrate to {@link #pageTitle()} or {@link #sectionTitle()}. */ + @Deprecated public static Font h1(int size) { return Font.font(DesignTokens.uiFontFamily(), FontWeight.BOLD, size); } + /** @deprecated Migrate to {@link #sectionTitle()}. */ + @Deprecated public static Font h2(int size) { return Font.font(DesignTokens.uiFontFamily(), FontWeight.BOLD, size); } + /** @deprecated Migrate to {@link #sectionTitle()}. */ + @Deprecated public static Font h3(int size) { return Font.font(DesignTokens.uiFontFamily(), FontWeight.BOLD, size); } + /** @deprecated Migrate to {@link #body()}. */ + @Deprecated public static Font body(int size) { return Font.font(DesignTokens.uiFontFamily(), size); } + /** @deprecated Migrate to {@link #secondary()}. */ + @Deprecated public static Font small(int size) { return Font.font(DesignTokens.uiFontFamily(), size); } + /** @deprecated Migrate to {@link #body()}. */ + @Deprecated public static Font base(int size) { return Font.font(DesignTokens.uiFontFamily(), size); } + /** @deprecated Migrate to {@link #sectionTitle()}. */ + @Deprecated public static Font bold(int size) { return Font.font(DesignTokens.uiFontFamily(), FontWeight.BOLD, size); } + /** @deprecated Migrate to {@link #mono()}. */ + @Deprecated public static Font mono(int size) { return Font.font(DesignTokens.monoFontFamily(), size); } + /** @deprecated Migrate to {@link #sectionTitle()}. */ + @Deprecated public static Font heading(int size) { return bold(size); } + /** @deprecated Migrate to {@link #secondary()}. */ + @Deprecated public static Font caption(int size) { return Font.font(DesignTokens.uiFontFamily(), size); } + + /** @deprecated Migrate to {@link #body()}. */ + @Deprecated + public static Font base(int size, String family) { + return Font.font(family, size); + } + /** @deprecated Migrate to {@link #sectionTitle()}. */ + @Deprecated + public static Font bold(int size, String family) { + return Font.font(family, FontWeight.BOLD, size); + } + + // ==================== 间距系统 ==================== + + public static final int S1 = 4; + public static final int S2 = 8; + public static final int S3 = 12; + public static final int S4 = 16; + public static final int S5 = 24; + public static final int S6 = 32; + + // ==================== 圆角 ==================== + + public static final int RADIUS_SM = 4; + public static final int RADIUS_MD = 6; + public static final int RADIUS_LG = 8; + + // ==================== 工具方法 ==================== + + public static String toWeb(Color c) { + return String.format("#%02x%02x%02x", + (int)(c.getRed() * 255), + (int)(c.getGreen() * 255), + (int)(c.getBlue() * 255)); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/AsyncOperationController.java b/src/main/java/com/example/homeutils/system/ui/async/AsyncOperationController.java new file mode 100644 index 0000000..d3a75b4 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/AsyncOperationController.java @@ -0,0 +1,236 @@ +package com.example.homeutils.system.ui.async; + +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; + +import java.util.Objects; +import java.util.concurrent.CancellationException; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +public final class AsyncOperationController { + + private final Executor executor; + private final Consumer uiDispatcher; + private final Object transitionLock = new Object(); + private final OperationStateMachine stateMachine = new OperationStateMachine(); + private final AtomicBoolean cancellationRequested = new AtomicBoolean(); + private final ConcurrentLinkedQueue publicationQueue = + new ConcurrentLinkedQueue<>(); + private final AtomicBoolean drainingPublications = new AtomicBoolean(); + private final ReadOnlyObjectWrapper snapshot = + new ReadOnlyObjectWrapper<>(OperationSnapshot.idle()); + private final ReadOnlyBooleanWrapper running = new ReadOnlyBooleanWrapper(false); + private boolean operationRunning; + private long nextGeneration; + private long activeGeneration; + private long nextPublicationSequence; + private long appliedGeneration; + private long appliedPublicationSequence; + + public AsyncOperationController(Executor executor, Consumer uiDispatcher) { + this.executor = Objects.requireNonNull(executor, "executor"); + this.uiDispatcher = Objects.requireNonNull(uiDispatcher, "uiDispatcher"); + } + + public void start(String runningMessage, OperationTask task, Consumer onSuccess) { + start(runningMessage, task, onSuccess, ignored -> { }); + } + + public void start(String runningMessage, OperationTask task, + Consumer onSuccess, Consumer onFailure) { + Objects.requireNonNull(task, "task"); + StartTransition startTransition; + synchronized (transitionLock) { + if (operationRunning) { + throw new IllegalStateException("Operation is already running"); + } + startTransition = prepareStart(runningMessage); + } + dispatchPublication(startTransition.publication()); + + executor.execute(() -> { + OperationContext context = new OperationContext( + (message, progress) -> dispatchProgress( + startTransition.generation(), message, progress), + cancellationRequested::get); + try { + T result = task.execute(context); + uiDispatcher.accept(() -> completeSuccess( + startTransition.generation(), result, onSuccess)); + } catch (Throwable error) { + if (error instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + uiDispatcher.accept(() -> completeFailure( + startTransition.generation(), error, onFailure)); + } + }); + } + + public void cancel() { + Publication publication; + synchronized (transitionLock) { + if (!operationRunning + || stateMachine.snapshot().state() != OperationState.RUNNING) { + return; + } + cancellationRequested.set(true); + OperationSnapshot cancelling = stateMachine.requestCancel(); + publication = publication(cancelling); + } + dispatchPublication(publication); + } + + public OperationSnapshot snapshot() { + return snapshot.get(); + } + + public ReadOnlyObjectProperty snapshotProperty() { + return snapshot.getReadOnlyProperty(); + } + + public ReadOnlyBooleanProperty runningProperty() { + return running.getReadOnlyProperty(); + } + + private StartTransition prepareStart(String message) { + OperationState current = stateMachine.snapshot().state(); + if (current == OperationState.SUCCEEDED || current == OperationState.FAILED + || current == OperationState.CANCELLED) { + stateMachine.reset(); + } + cancellationRequested.set(false); + OperationSnapshot started = stateMachine.start(message); + operationRunning = true; + activeGeneration = ++nextGeneration; + return new StartTransition(activeGeneration, publication(started)); + } + + private void dispatchProgress(long generation, String message, double progress) { + uiDispatcher.accept(() -> applyProgress(generation, message, progress)); + } + + private void applyProgress(long generation, String message, double progress) { + Publication publication; + synchronized (transitionLock) { + if (generation != activeGeneration + || stateMachine.snapshot().state() != OperationState.RUNNING) { + return; + } + publication = publication(stateMachine.progress(message, progress)); + } + enqueuePublication(publication); + } + + private void completeSuccess(long generation, T result, Consumer onSuccess) { + Publication publication; + synchronized (transitionLock) { + if (generation != activeGeneration) { + return; + } + OperationState state = stateMachine.snapshot().state(); + OperationSnapshot terminal; + if (state == OperationState.RUNNING) { + terminal = stateMachine.succeed("操作完成"); + } else if (state == OperationState.CANCELLING) { + terminal = stateMachine.cancel(); + } else { + return; + } + operationRunning = false; + publication = publication(terminal); + } + enqueuePublication(publication); + onSuccess.accept(result); + } + + private void completeFailure(long generation, Throwable error, + Consumer onFailure) { + boolean cancelled; + Publication publication; + synchronized (transitionLock) { + if (generation != activeGeneration) { + return; + } + OperationState state = stateMachine.snapshot().state(); + if (state != OperationState.RUNNING + && state != OperationState.CANCELLING) { + return; + } + cancelled = state == OperationState.CANCELLING + && cancellationRequested.get() + && (error instanceof CancellationException + || error instanceof InterruptedException); + OperationSnapshot terminal = cancelled + ? stateMachine.cancel() + : stateMachine.fail(readableMessage(error)); + operationRunning = false; + publication = publication(terminal); + } + enqueuePublication(publication); + if (!cancelled) { + onFailure.accept(error); + } + } + + private Publication publication(OperationSnapshot value) { + return new Publication(activeGeneration, ++nextPublicationSequence, + operationRunning, value); + } + + private void dispatchPublication(Publication publication) { + uiDispatcher.accept(() -> enqueuePublication(publication)); + } + + private void enqueuePublication(Publication publication) { + publicationQueue.add(publication); + drainPublications(); + } + + private void drainPublications() { + if (!drainingPublications.compareAndSet(false, true)) { + return; + } + try { + Publication publication; + while ((publication = publicationQueue.poll()) != null) { + applyPublication(publication); + } + } finally { + drainingPublications.set(false); + if (!publicationQueue.isEmpty()) { + drainPublications(); + } + } + } + + private void applyPublication(Publication publication) { + if (publication.sequence() <= appliedPublicationSequence + || publication.generation() < appliedGeneration) { + return; + } + appliedGeneration = publication.generation(); + appliedPublicationSequence = publication.sequence(); + running.set(publication.running()); + snapshot.set(publication.snapshot()); + } + + private String readableMessage(Throwable error) { + String message = error.getMessage(); + return message == null || message.isBlank() + ? error.getClass().getSimpleName() + : message; + } + + private record StartTransition(long generation, Publication publication) { + } + + private record Publication(long generation, long sequence, boolean running, + OperationSnapshot snapshot) { + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/LoadingButtonBinder.java b/src/main/java/com/example/homeutils/system/ui/async/LoadingButtonBinder.java new file mode 100644 index 0000000..fd1406b --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/LoadingButtonBinder.java @@ -0,0 +1,168 @@ +package com.example.homeutils.system.ui.async; + +import javafx.beans.value.ChangeListener; +import javafx.css.PseudoClass; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.Region; +import javafx.scene.text.Text; + +import java.util.Objects; + +public final class LoadingButtonBinder { + + private static final double SPINNER_SIZE = 14; + private static final PseudoClass RUNNING = PseudoClass.getPseudoClass("running"); + private static final Object BINDING_KEY = new Object(); + + private LoadingButtonBinder() { + } + + public static void bind(Button button, AsyncOperationController controller, + String idleText) { + bind(button, controller, idleText, null); + } + + public static void bindCompact(Button button, AsyncOperationController controller, + String idleText, String busyAccessibleText) { + Objects.requireNonNull(busyAccessibleText, "busyAccessibleText"); + bind(button, controller, idleText, busyAccessibleText); + } + + private static void bind(Button button, AsyncOperationController controller, + String idleText, String busyAccessibleText) { + Objects.requireNonNull(button, "button"); + Objects.requireNonNull(controller, "controller"); + Objects.requireNonNull(idleText, "idleText"); + ButtonBinding binding = (ButtonBinding) button.getProperties().get(BINDING_KEY); + if (binding == null) { + binding = new ButtonBinding(button, idleText); + button.getProperties().put(BINDING_KEY, binding); + } + binding.busyAccessibleText = busyAccessibleText; + binding.bind(controller); + } + + public static String textFor(OperationSnapshot snapshot, String idleText) { + return switch (snapshot.state()) { + case RUNNING -> { + String message = snapshot.message(); + yield (message == null || message.isBlank() ? "处理中" : trimEllipsis(message)) + "…"; + } + case CANCELLING -> "正在停止…"; + default -> idleText; + }; + } + + private static void apply(ButtonBinding binding, OperationSnapshot snapshot) { + Button button = binding.button; + boolean busy = snapshot.state() == OperationState.RUNNING + || snapshot.state() == OperationState.CANCELLING; + boolean compact = binding.busyAccessibleText != null; + String visibleText = busy && compact + ? "" + : textFor(snapshot, binding.idleText); + button.setText(visibleText); + button.setAccessibleText(busy + ? compact ? binding.busyAccessibleText : visibleText + : binding.idleAccessibleText); + button.setDisable(busy); + button.setGraphic(busy ? binding.spinner : binding.idleGraphic); + button.pseudoClassStateChanged(RUNNING, busy); + } + + private static void preserveIdleWidth(ButtonBinding binding) { + Button button = binding.button; + Text text = new Text(binding.idleText); + text.setFont(button.getFont()); + double idleWidth = text.getLayoutBounds().getWidth(); + if (binding.idleGraphic != null) { + double graphicWidth = preferredWidth(binding.idleGraphic); + idleWidth += graphicWidth; + if (!binding.idleText.isEmpty() && graphicWidth > 0) { + idleWidth += button.getGraphicTextGap(); + } + } + Insets padding = button.getPadding(); + idleWidth += padding.getLeft() + padding.getRight(); + double configuredMinWidth = button.getMinWidth(); + if (configuredMinWidth >= 0) { + idleWidth = Math.max(idleWidth, configuredMinWidth); + } + if (Double.isFinite(idleWidth) && idleWidth > 0) { + button.setMinWidth(Math.ceil(idleWidth)); + } + } + + private static double preferredWidth(Node node) { + if (node instanceof Region region) { + double prefWidth = region.prefWidth(-1); + if (Double.isFinite(prefWidth) && prefWidth > 0) { + return prefWidth; + } + } + Bounds bounds = node.getLayoutBounds(); + return Math.max(0, bounds.getWidth()); + } + + private static String trimEllipsis(String message) { + String trimmed = message.trim(); + while (trimmed.endsWith("…")) { + trimmed = trimmed.substring(0, trimmed.length() - 1); + } + return trimmed; + } + + private static ProgressIndicator createSpinner() { + ProgressIndicator spinner = new ProgressIndicator(); + spinner.getStyleClass().add("loading-spinner"); + spinner.setPrefSize(SPINNER_SIZE, SPINNER_SIZE); + spinner.setMinSize(SPINNER_SIZE, SPINNER_SIZE); + spinner.setMaxSize(SPINNER_SIZE, SPINNER_SIZE); + return spinner; + } + + private static final class ButtonBinding { + + private final Button button; + private final String idleText; + private final Node idleGraphic; + private final String idleAccessibleText; + private final ProgressIndicator spinner = createSpinner(); + private String busyAccessibleText; + private AsyncOperationController controller; + private ChangeListener snapshotListener; + + private ButtonBinding(Button button, String idleText) { + this.button = button; + this.idleText = idleText; + idleGraphic = button.getGraphic(); + idleAccessibleText = button.getAccessibleText(); + button.sceneProperty().addListener( + (observable, oldScene, newScene) -> recalibrate(newScene != null)); + button.skinProperty().addListener( + (observable, oldSkin, newSkin) -> recalibrate(newSkin != null)); + recalibrate(button.getScene() != null); + } + + private void bind(AsyncOperationController newController) { + if (controller != null && snapshotListener != null) { + controller.snapshotProperty().removeListener(snapshotListener); + } + controller = newController; + snapshotListener = (observable, oldValue, snapshot) -> apply(this, snapshot); + controller.snapshotProperty().addListener(snapshotListener); + apply(this, controller.snapshot()); + } + + private void recalibrate(boolean applyCss) { + if (applyCss) { + button.applyCss(); + } + preserveIdleWidth(this); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationContext.java b/src/main/java/com/example/homeutils/system/ui/async/OperationContext.java new file mode 100644 index 0000000..db8a1f0 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationContext.java @@ -0,0 +1,28 @@ +package com.example.homeutils.system.ui.async; + +import java.util.function.BooleanSupplier; +import java.util.function.BiConsumer; + +public final class OperationContext { + + private final BiConsumer progressReporter; + private final BooleanSupplier cancellationRequested; + + OperationContext(BiConsumer progressReporter, + BooleanSupplier cancellationRequested) { + this.progressReporter = progressReporter; + this.cancellationRequested = cancellationRequested; + } + + public static OperationContext none() { + return new OperationContext((message, progress) -> {}, () -> false); + } + + public void report(String message, double progress) { + progressReporter.accept(message, progress); + } + + public boolean isCancellationRequested() { + return cancellationRequested.getAsBoolean(); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationSnapshot.java b/src/main/java/com/example/homeutils/system/ui/async/OperationSnapshot.java new file mode 100644 index 0000000..4a4c011 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationSnapshot.java @@ -0,0 +1,8 @@ +package com.example.homeutils.system.ui.async; + +public record OperationSnapshot(OperationState state, String message, double progress) { + + public static OperationSnapshot idle() { + return new OperationSnapshot(OperationState.IDLE, "", 0); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationState.java b/src/main/java/com/example/homeutils/system/ui/async/OperationState.java new file mode 100644 index 0000000..57d4363 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationState.java @@ -0,0 +1,10 @@ +package com.example.homeutils.system.ui.async; + +public enum OperationState { + IDLE, + RUNNING, + CANCELLING, + CANCELLED, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationStateMachine.java b/src/main/java/com/example/homeutils/system/ui/async/OperationStateMachine.java new file mode 100644 index 0000000..9797c7f --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationStateMachine.java @@ -0,0 +1,67 @@ +package com.example.homeutils.system.ui.async; + +import java.util.Objects; + +public final class OperationStateMachine { + + private OperationSnapshot snapshot = OperationSnapshot.idle(); + + public synchronized OperationSnapshot snapshot() { + return snapshot; + } + + public synchronized OperationSnapshot start(String message) { + requireState(OperationState.IDLE); + return update(OperationState.RUNNING, message, -1); + } + + public synchronized OperationSnapshot progress(String message, double progress) { + requireState(OperationState.RUNNING); + return update(OperationState.RUNNING, message, normalizeProgress(progress)); + } + + public synchronized OperationSnapshot requestCancel() { + requireState(OperationState.RUNNING); + return update(OperationState.CANCELLING, "正在停止…", snapshot.progress()); + } + + public synchronized OperationSnapshot succeed(String message) { + requireState(OperationState.RUNNING); + return update(OperationState.SUCCEEDED, message, 1); + } + + public synchronized OperationSnapshot cancel() { + requireState(OperationState.CANCELLING); + return update(OperationState.CANCELLED, "已取消", snapshot.progress()); + } + + public synchronized OperationSnapshot fail(String message) { + requireState(OperationState.RUNNING, OperationState.CANCELLING); + return update(OperationState.FAILED, message, snapshot.progress()); + } + + public synchronized OperationSnapshot reset() { + requireState(OperationState.SUCCEEDED, OperationState.FAILED, + OperationState.CANCELLED); + snapshot = OperationSnapshot.idle(); + return snapshot; + } + + private OperationSnapshot update(OperationState state, String message, double progress) { + snapshot = new OperationSnapshot(state, Objects.requireNonNullElse(message, ""), progress); + return snapshot; + } + + private double normalizeProgress(double progress) { + if (progress < 0) return -1; + return Math.min(progress, 1); + } + + private void requireState(OperationState... allowed) { + OperationState current = snapshot.state(); + for (OperationState state : allowed) { + if (state == current) return; + } + throw new IllegalStateException("Operation state " + current + " does not allow this transition"); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationStatusPane.java b/src/main/java/com/example/homeutils/system/ui/async/OperationStatusPane.java new file mode 100644 index 0000000..b900204 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationStatusPane.java @@ -0,0 +1,130 @@ +package com.example.homeutils.system.ui.async; + +import com.example.homeutils.system.ui.Theme; +import javafx.beans.value.ChangeListener; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressBar; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; + +import java.util.Objects; + +public final class OperationStatusPane extends HBox { + + private final ProgressBar progressBar = new ProgressBar(0); + private final Label messageLabel = new Label("就绪"); + private final Label percentageLabel = new Label(); + private final Button cancelButton; + private final AsyncOperationController controller; + private final ChangeListener snapshotListener; + private boolean disposed; + + /** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */ + @Deprecated + public OperationStatusPane(AsyncOperationController controller, int fontSize) { + this(controller, true); + } + + /** @deprecated Use {@link #OperationStatusPane(AsyncOperationController, boolean)}. */ + @Deprecated + public OperationStatusPane(AsyncOperationController controller, int fontSize, + boolean cancellable) { + this(controller, cancellable); + } + + public OperationStatusPane(AsyncOperationController controller, boolean cancellable) { + super(Theme.S2); + Objects.requireNonNull(controller, "controller"); + this.controller = controller; + setAlignment(Pos.CENTER_LEFT); + getStyleClass().add("operation-status"); + + progressBar.setPrefWidth(240); + progressBar.setAccessibleText("任务进度"); + messageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); + messageLabel.setAccessibleText("任务状态"); + percentageLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); + percentageLabel.setAccessibleText("任务进度百分比"); + percentageLabel.setFocusTraversable(false); + HBox.setHgrow(messageLabel, Priority.ALWAYS); + + getChildren().addAll(progressBar, messageLabel, percentageLabel); + if (cancellable) { + cancelButton = new Button("停止"); + cancelButton.getStyleClass().add(Theme.CSS_BTN_DANGER); + cancelButton.setAccessibleText("停止当前任务"); + cancelButton.setFocusTraversable(true); + cancelButton.setOnAction(event -> controller.cancel()); + getChildren().add(cancelButton); + } else { + cancelButton = null; + } + snapshotListener = (observable, oldValue, value) -> render(value); + controller.snapshotProperty().addListener(snapshotListener); + render(controller.snapshot()); + } + + /** + * Releases controller listeners when this pane is no longer used. + * Calling this method more than once is safe. + */ + public void dispose() { + if (disposed) { + return; + } + disposed = true; + controller.snapshotProperty().removeListener(snapshotListener); + if (cancelButton != null) { + cancelButton.setOnAction(null); + } + } + + private void render(OperationSnapshot snapshot) { + progressBar.setProgress(snapshot.progress()); + boolean active = snapshot.state() == OperationState.RUNNING + || snapshot.state() == OperationState.CANCELLING; + boolean determinate = active && Double.isFinite(snapshot.progress()) + && snapshot.progress() >= 0; + percentageLabel.setText(determinate + ? Math.round(snapshot.progress() * 100) + "%" + : ""); + percentageLabel.setVisible(determinate); + percentageLabel.setManaged(determinate); + messageLabel.setText(snapshot.message().isBlank() ? defaultMessage(snapshot.state()) + : snapshot.message()); + String stateDescription = "任务状态:" + messageLabel.getText() + + (determinate ? ",进度 " + percentageLabel.getText() : ""); + setAccessibleText(stateDescription); + messageLabel.setAccessibleText(stateDescription); + progressBar.setAccessibleText("任务进度:" + stateDescription); + percentageLabel.setAccessibleText(determinate + ? "任务进度:" + percentageLabel.getText() + : "任务进度未知"); + if (cancelButton != null) { + cancelButton.setVisible(snapshot.state() == OperationState.RUNNING); + cancelButton.setManaged(cancelButton.isVisible()); + cancelButton.setDisable(snapshot.state() != OperationState.RUNNING); + } + + getStyleClass().removeAll( + "operation-status-success", "operation-status-error"); + if (snapshot.state() == OperationState.SUCCEEDED) { + getStyleClass().add("operation-status-success"); + } else if (snapshot.state() == OperationState.FAILED) { + getStyleClass().add("operation-status-error"); + } + } + + private String defaultMessage(OperationState state) { + return switch (state) { + case IDLE -> "就绪"; + case RUNNING -> "处理中…"; + case CANCELLING -> "正在停止…"; + case CANCELLED -> "已取消"; + case SUCCEEDED -> "操作完成"; + case FAILED -> "操作失败"; + }; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/async/OperationTask.java b/src/main/java/com/example/homeutils/system/ui/async/OperationTask.java new file mode 100644 index 0000000..924e59f --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/async/OperationTask.java @@ -0,0 +1,6 @@ +package com.example.homeutils.system.ui.async; + +@FunctionalInterface +public interface OperationTask { + T execute(OperationContext context) throws Exception; +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/ExportHistoryPane.java b/src/main/java/com/example/homeutils/system/ui/history/ExportHistoryPane.java new file mode 100644 index 0000000..542990d --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/ExportHistoryPane.java @@ -0,0 +1,239 @@ +package com.example.homeutils.system.ui.history; + +import com.example.homeutils.system.ui.EmptyStatePane; +import com.example.homeutils.system.ui.StyledButton; +import com.example.homeutils.system.ui.Theme; +import com.example.homeutils.system.support.AppExecutors; +import com.example.homeutils.system.ui.async.AsyncOperationController; +import com.example.homeutils.system.ui.async.LoadingButtonBinder; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.collections.ObservableList; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.MenuItem; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.Tooltip; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.Function; + +public final class ExportHistoryPane extends VBox { + + private static final double ACTION_WIDTH = 150; + private static final double INDEX_WIDTH = 64; + private static final double QUERY_WIDTH = 140; + private static final double FILE_MIN_WIDTH = 220; + private static final double COUNT_WIDTH = 88; + private static final double TIME_WIDTH = 180; + private static final Duration ACTION_BUSY_MINIMUM = Duration.ofMillis(240); + + private final TableView table = new TableView<>(); + + public ExportHistoryPane( + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize) { + this("查询条件", countColumnTitle, items, commands, fontSize); + } + + public ExportHistoryPane( + String queryColumnTitle, + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize) { + this(queryColumnTitle, countColumnTitle, items, commands, fontSize, + AppExecutors.io(), Platform::runLater, ACTION_BUSY_MINIMUM); + } + + ExportHistoryPane( + String queryColumnTitle, + String countColumnTitle, + ObservableList items, + HistoryFileCommands commands, + int fontSize, + Executor actionExecutor, + Consumer uiDispatcher, + Duration minimumBusyDuration) { + super(Theme.S2); + Objects.requireNonNull(queryColumnTitle, "queryColumnTitle"); + Objects.requireNonNull(countColumnTitle, "countColumnTitle"); + Objects.requireNonNull(items, "items"); + Objects.requireNonNull(commands, "commands"); + Objects.requireNonNull(actionExecutor, "actionExecutor"); + Objects.requireNonNull(uiDispatcher, "uiDispatcher"); + Objects.requireNonNull(minimumBusyDuration, "minimumBusyDuration"); + if (minimumBusyDuration.isNegative()) { + throw new IllegalArgumentException("minimumBusyDuration must not be negative"); + } + + table.setItems(items); + table.setAccessibleText("导出历史记录"); + table.setFocusTraversable(true); + table.setOnKeyPressed(event -> { + if (event.getCode() == KeyCode.ENTER) { + HistoryItem selected = table.getSelectionModel().getSelectedItem(); + if (selected != null) { + commands.openFile(selected.filePath()); + event.consume(); + } + } + }); + table.setColumnResizePolicy(TableView.CONSTRAINED_RESIZE_POLICY); + table.setPlaceholder(new EmptyStatePane( + new EmptyStatePane.Content( + "暂无历史记录", "完成一次任务后,生成的文件会显示在这里。", ""), + null, fontSize)); + table.getStyleClass().add("export-history-table"); + + TableColumn actionColumn = actionColumn( + commands, fontSize, actionExecutor, uiDispatcher, minimumBusyDuration); + TableColumn indexColumn = textColumn("序号", HistoryItem::index); + TableColumn queryColumn = textColumn(queryColumnTitle, HistoryItem::query); + TableColumn fileColumn = fileColumn(); + TableColumn countColumn = textColumn(countColumnTitle, HistoryItem::count); + TableColumn timeColumn = textColumn("时间", HistoryItem::timestamp); + + fixWidth(actionColumn, ACTION_WIDTH); + fixWidth(indexColumn, INDEX_WIDTH); + fixWidth(queryColumn, QUERY_WIDTH); + fileColumn.setMinWidth(FILE_MIN_WIDTH); + fileColumn.setMaxWidth(Double.MAX_VALUE); + fileColumn.setResizable(true); + fixWidth(countColumn, COUNT_WIDTH); + fixWidth(timeColumn, TIME_WIDTH); + + table.getColumns().addAll( + actionColumn, indexColumn, queryColumn, fileColumn, countColumn, timeColumn); + + VBox.setVgrow(table, Priority.ALWAYS); + getChildren().add(table); + } + + public TableView table() { + return table; + } + + private void fixWidth(TableColumn column, double width) { + column.setMinWidth(width); + column.setPrefWidth(width); + column.setMaxWidth(width); + column.setResizable(false); + } + + private TableColumn textColumn( + String title, Function value) { + TableColumn column = new TableColumn<>(title); + column.setCellValueFactory(cell -> + new ReadOnlyStringWrapper(value.apply(cell.getValue()))); + return column; + } + + private TableColumn fileColumn() { + TableColumn column = + textColumn("生成文件", item -> item.fileDisplay(42)); + column.setCellFactory(ignored -> new TableCell<>() { + @Override + protected void updateItem(String value, boolean empty) { + super.updateItem(value, empty); + setText(empty ? null : value); + HistoryItem row = empty ? null : getTableRow().getItem(); + setTooltip(row == null ? null : new Tooltip(row.filePath())); + setAccessibleText(row == null ? null : "生成文件:" + row.filePath()); + } + }); + return column; + } + + private TableColumn actionColumn( + HistoryFileCommands commands, + int fontSize, + Executor actionExecutor, + Consumer uiDispatcher, + Duration minimumBusyDuration) { + TableColumn column = new TableColumn<>("操作"); + column.setCellFactory(ignored -> new TableCell<>() { + private final Button open = StyledButton.small( + "打开", Theme.base(fontSize - 1)); + private final Button folder = StyledButton.small( + "目录", Theme.base(fontSize - 1)); + private final HBox actions = new HBox(4, open, folder); + private final ContextMenu menu = new ContextMenu(); + private final AsyncOperationController openController = + new AsyncOperationController<>(actionExecutor, uiDispatcher); + private final AsyncOperationController folderController = + new AsyncOperationController<>(actionExecutor, uiDispatcher); + private HistoryItem row; + + { + actions.setAlignment(Pos.CENTER); + open.setAccessibleText("打开生成文件"); + folder.setAccessibleText("打开生成文件所在目录"); + folder.setTooltip(new Tooltip("打开文件所在目录")); + LoadingButtonBinder.bindCompact( + open, openController, "打开", "正在打开生成文件"); + LoadingButtonBinder.bindCompact( + folder, folderController, "目录", "正在打开生成文件所在目录"); + MenuItem edit = new MenuItem("用系统编辑器打开"); + MenuItem copy = new MenuItem("复制文件路径"); + edit.setOnAction(event -> commands.openInEditor(row.filePath())); + copy.setOnAction(event -> commands.copyPath(row.filePath())); + menu.getItems().addAll(edit, copy); + actions.setOnContextMenuRequested(event -> + menu.show(actions, event.getScreenX(), event.getScreenY())); + open.setOnAction(event -> { + String path = row.filePath(); + runAction(openController, "正在打开", minimumBusyDuration, + () -> commands.openFile(path)); + }); + folder.setOnAction(event -> { + String path = row.filePath(); + runAction(folderController, "正在打开目录", minimumBusyDuration, + () -> commands.openFolder(path)); + }); + } + + @Override + protected void updateItem(Void value, boolean empty) { + super.updateItem(value, empty); + row = empty ? null : getTableRow().getItem(); + setGraphic(row == null ? null : actions); + setAccessibleText(row == null ? null : "历史记录操作:" + row.filePath()); + } + }); + return column; + } + + private static void runAction( + AsyncOperationController controller, + String message, + Duration minimumBusyDuration, + Runnable action) { + controller.start(message, context -> { + long startedAt = System.nanoTime(); + try { + action.run(); + } finally { + long remainingNanos = minimumBusyDuration.toNanos() + - (System.nanoTime() - startedAt); + if (remainingNanos > 0) { + TimeUnit.NANOSECONDS.sleep(remainingNanos); + } + } + return null; + }, ignored -> { }); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/FileActionGateway.java b/src/main/java/com/example/homeutils/system/ui/history/FileActionGateway.java new file mode 100644 index 0000000..928b1f9 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/FileActionGateway.java @@ -0,0 +1,15 @@ +package com.example.homeutils.system.ui.history; + +import java.io.IOException; +import java.nio.file.Path; + +public interface FileActionGateway { + + void open(Path path) throws IOException; + + void edit(Path path) throws IOException; + + void reveal(Path path) throws IOException; + + void copyPath(String path); +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/HistoryFileCommands.java b/src/main/java/com/example/homeutils/system/ui/history/HistoryFileCommands.java new file mode 100644 index 0000000..9ad8e5a --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/HistoryFileCommands.java @@ -0,0 +1,39 @@ +package com.example.homeutils.system.ui.history; + +import java.util.Objects; +import java.util.function.Consumer; + +public final class HistoryFileCommands { + + private final Consumer openFile; + private final Consumer openInEditor; + private final Consumer openFolder; + private final Consumer copyPath; + + public HistoryFileCommands( + Consumer openFile, + Consumer openInEditor, + Consumer openFolder, + Consumer copyPath) { + this.openFile = Objects.requireNonNull(openFile, "openFile"); + this.openInEditor = Objects.requireNonNull(openInEditor, "openInEditor"); + this.openFolder = Objects.requireNonNull(openFolder, "openFolder"); + this.copyPath = Objects.requireNonNull(copyPath, "copyPath"); + } + + public void openFile(String path) { + openFile.accept(path); + } + + public void openInEditor(String path) { + openInEditor.accept(path); + } + + public void openFolder(String path) { + openFolder.accept(path); + } + + public void copyPath(String path) { + copyPath.accept(path); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/HistoryItem.java b/src/main/java/com/example/homeutils/system/ui/history/HistoryItem.java new file mode 100644 index 0000000..59c039d --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/HistoryItem.java @@ -0,0 +1,36 @@ +package com.example.homeutils.system.ui.history; + +import java.nio.file.Path; +import java.util.Objects; + +public record HistoryItem( + String index, + String query, + String filePath, + String count, + String timestamp) { + + public HistoryItem { + Objects.requireNonNull(index, "index"); + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(filePath, "filePath"); + Objects.requireNonNull(count, "count"); + Objects.requireNonNull(timestamp, "timestamp"); + } + + public String fileDisplay(int maxLength) { + if (maxLength < 1) { + throw new IllegalArgumentException("maxLength must be positive"); + } + if (filePath.length() <= maxLength) { + return filePath; + } + String fileName = Path.of(filePath).getFileName().toString(); + String shortened = "…/" + fileName; + if (shortened.length() <= maxLength) { + return shortened; + } + int visible = Math.max(1, maxLength - 1); + return "…" + fileName.substring(Math.max(0, fileName.length() - visible)); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/ResultFileActions.java b/src/main/java/com/example/homeutils/system/ui/history/ResultFileActions.java new file mode 100644 index 0000000..5233581 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/ResultFileActions.java @@ -0,0 +1,90 @@ +package com.example.homeutils.system.ui.history; + +import com.example.homeutils.system.ui.notification.NotificationCenter; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.util.Objects; +import java.util.function.Consumer; + +public final class ResultFileActions { + + private final FileActionGateway gateway; + private final NotificationCenter notifications; + private final Consumer successFeedback; + + public ResultFileActions( + FileActionGateway gateway, + NotificationCenter notifications, + Consumer successFeedback) { + this.gateway = Objects.requireNonNull(gateway, "gateway"); + this.notifications = Objects.requireNonNull(notifications, "notifications"); + this.successFeedback = Objects.requireNonNull(successFeedback, "successFeedback"); + } + + public HistoryFileCommands commands() { + return new HistoryFileCommands( + path -> performExisting(path, "打开文件", gateway::open), + path -> performExisting(path, "编辑文件", gateway::edit), + path -> performExisting(path, "打开目录", gateway::reveal), + this::copyPath); + } + + private void performExisting(String value, String action, PathAction operation) { + Path path = parsePath(value); + if (path == null) { + return; + } + if (!Files.isRegularFile(path)) { + notifications.warning("文件操作失败", "文件不存在"); + return; + } + try { + operation.accept(path); + } catch (IOException | RuntimeException error) { + notifyFailure(action, error); + } + } + + private void copyPath(String value) { + Path path = parsePath(value); + if (path == null) { + return; + } + String normalized = path.toString(); + try { + gateway.copyPath(normalized); + successFeedback.accept(Files.exists(path) + ? "已复制路径: " + normalized + : "已复制路径(文件已不存在): " + normalized); + } catch (RuntimeException error) { + notifyFailure("复制路径", error); + } + } + + private Path parsePath(String value) { + if (value == null || value.isBlank()) { + notifications.warning("文件操作失败", "文件路径不能为空"); + return null; + } + try { + return Path.of(value.trim()).toAbsolutePath().normalize(); + } catch (InvalidPathException error) { + notifyFailure("解析路径", error); + return null; + } + } + + private void notifyFailure(String action, Exception error) { + String detail = error.getMessage(); + notifications.error("文件操作失败", action + "失败" + + (detail == null || detail.isBlank() ? "" : ": " + detail)); + } + + @FunctionalInterface + private interface PathAction { + void accept(Path path) throws IOException; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/history/SystemFileActionGateway.java b/src/main/java/com/example/homeutils/system/ui/history/SystemFileActionGateway.java new file mode 100644 index 0000000..4faeec2 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/history/SystemFileActionGateway.java @@ -0,0 +1,33 @@ +package com.example.homeutils.system.ui.history; + +import com.example.homeutils.system.support.SystemOpenHelper; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; + +import java.io.IOException; +import java.nio.file.Path; + +public final class SystemFileActionGateway implements FileActionGateway { + + @Override + public void open(Path path) throws IOException { + SystemOpenHelper.openFile(path); + } + + @Override + public void edit(Path path) throws IOException { + SystemOpenHelper.editFile(path); + } + + @Override + public void reveal(Path path) throws IOException { + SystemOpenHelper.revealInFileManager(path); + } + + @Override + public void copyPath(String path) { + ClipboardContent content = new ClipboardContent(); + content.putString(path); + Clipboard.getSystemClipboard().setContent(content); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/icon/AppIcon.java b/src/main/java/com/example/homeutils/system/ui/icon/AppIcon.java new file mode 100644 index 0000000..de53460 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/icon/AppIcon.java @@ -0,0 +1,33 @@ +package com.example.homeutils.system.ui.icon; + +import org.kordamp.ikonli.Ikon; +import org.kordamp.ikonli.feather.Feather; + +public enum AppIcon { + MENU(Feather.MENU), + SETTINGS(Feather.SETTINGS), + CHEVRON_LEFT(Feather.CHEVRON_LEFT), + CHEVRON_RIGHT(Feather.CHEVRON_RIGHT), + GIT_SYNC(Feather.REFRESH_CW), + GIT_REVIEW(Feather.GIT_BRANCH), + DIFF(Feather.COLUMNS), + SQL_EXPORT(Feather.DATABASE), + MYBATIS_GENERATOR(Feather.CODE), + SCANNER(Feather.SEARCH), + API_DOC(Feather.FILE_TEXT), + AI_TEST(Feather.CPU), + EXPORT(Feather.DOWNLOAD), + CANCEL(Feather.X), + CHAT(Feather.MESSAGE_SQUARE), + APP(Feather.TOOL); + + private final Feather glyph; + + AppIcon(Feather glyph) { + this.glyph = glyph; + } + + public Ikon glyph() { + return glyph; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/icon/IconFactory.java b/src/main/java/com/example/homeutils/system/ui/icon/IconFactory.java new file mode 100644 index 0000000..0c2d927 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/icon/IconFactory.java @@ -0,0 +1,25 @@ +package com.example.homeutils.system.ui.icon; + +import org.kordamp.ikonli.javafx.FontIcon; + +import java.util.Objects; + +public final class IconFactory { + private static final int BUTTON_ICON_SIZE = 16; + private static final String ICON_STYLE_CLASS = "app-icon"; + + private IconFactory() { + } + + public static FontIcon create(AppIcon icon, int size) { + FontIcon node = FontIcon.of(Objects.requireNonNull(icon, "icon must not be null").glyph(), size); + node.getStyleClass().add(ICON_STYLE_CLASS); + return node; + } + + public static FontIcon createButtonIcon(AppIcon icon, String accessibleText) { + FontIcon node = create(icon, BUTTON_ICON_SIZE); + node.setAccessibleText(Objects.requireNonNull(accessibleText, "accessibleText must not be null")); + return node; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/notification/FxNotificationCenter.java b/src/main/java/com/example/homeutils/system/ui/notification/FxNotificationCenter.java new file mode 100644 index 0000000..f816796 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/notification/FxNotificationCenter.java @@ -0,0 +1,106 @@ +package com.example.homeutils.system.ui.notification; + +import javafx.application.Platform; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonType; +import javafx.scene.control.TextArea; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.Priority; +import javafx.stage.Window; + +import java.util.Objects; + +public final class FxNotificationCenter implements NotificationCenter { + + private static final int LONG_MESSAGE_LENGTH = 240; + + private final Window owner; + + public FxNotificationCenter(Window owner) { + this.owner = owner; + } + + @Override + public void show(NotificationMessage message) { + Objects.requireNonNull(message, "message"); + if (Platform.isFxApplicationThread()) { + showNow(message); + } else { + Platform.runLater(() -> showNow(message)); + } + } + + @Override + public boolean confirm(String title, String header, String message) { + if (!Platform.isFxApplicationThread()) { + throw new IllegalStateException( + "confirm must be called on the JavaFX Application Thread"); + } + String normalizedMessage = requireMessage(message); + Alert alert = createAlert(Alert.AlertType.CONFIRMATION, title); + alert.setHeaderText(normalizeOptional(header)); + setMessage(alert, normalizedMessage); + return alert.showAndWait().filter(ButtonType.OK::equals).isPresent(); + } + + private void showNow(NotificationMessage message) { + Alert alert = createAlert(alertType(message.level()), message.title()); + alert.setHeaderText(null); + setMessage(alert, message.message()); + alert.showAndWait(); + } + + private Alert createAlert(Alert.AlertType type, String title) { + Alert alert = new Alert(type); + alert.setTitle(normalizeTitle(title)); + if (owner != null) { + alert.initOwner(owner); + } + return alert; + } + + private void setMessage(Alert alert, String message) { + if (!message.contains("\n") && message.length() <= LONG_MESSAGE_LENGTH) { + alert.setContentText(message); + return; + } + TextArea details = new TextArea(message); + details.setEditable(false); + details.setWrapText(true); + details.setMaxWidth(Double.MAX_VALUE); + details.setMaxHeight(Double.MAX_VALUE); + GridPane.setVgrow(details, Priority.ALWAYS); + GridPane.setHgrow(details, Priority.ALWAYS); + alert.getDialogPane().setExpandableContent(details); + alert.getDialogPane().setExpanded(true); + } + + private Alert.AlertType alertType(NotificationLevel level) { + return switch (level) { + case INFO -> Alert.AlertType.INFORMATION; + case WARNING -> Alert.AlertType.WARNING; + case ERROR -> Alert.AlertType.ERROR; + }; + } + + private String normalizeTitle(String title) { + String normalized = title == null ? "" : title.trim(); + return normalized.isEmpty() ? "提示" : normalized; + } + + private String normalizeOptional(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + private String requireMessage(String message) { + String normalized = Objects.requireNonNull(message, "message").trim(); + if (normalized.isEmpty()) { + throw new IllegalArgumentException("message must not be blank"); + } + return normalized; + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/notification/NotificationCenter.java b/src/main/java/com/example/homeutils/system/ui/notification/NotificationCenter.java new file mode 100644 index 0000000..8eea76a --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/notification/NotificationCenter.java @@ -0,0 +1,24 @@ +package com.example.homeutils.system.ui.notification; + +import static com.example.homeutils.system.ui.notification.NotificationLevel.ERROR; +import static com.example.homeutils.system.ui.notification.NotificationLevel.INFO; +import static com.example.homeutils.system.ui.notification.NotificationLevel.WARNING; + +public interface NotificationCenter { + + void show(NotificationMessage message); + + boolean confirm(String title, String header, String message); + + default void info(String title, String message) { + show(new NotificationMessage(INFO, title, message)); + } + + default void warning(String title, String message) { + show(new NotificationMessage(WARNING, title, message)); + } + + default void error(String title, String message) { + show(new NotificationMessage(ERROR, title, message)); + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/notification/NotificationLevel.java b/src/main/java/com/example/homeutils/system/ui/notification/NotificationLevel.java new file mode 100644 index 0000000..55c4f5e --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/notification/NotificationLevel.java @@ -0,0 +1,7 @@ +package com.example.homeutils.system.ui.notification; + +public enum NotificationLevel { + INFO, + WARNING, + ERROR +} diff --git a/src/main/java/com/example/homeutils/system/ui/notification/NotificationMessage.java b/src/main/java/com/example/homeutils/system/ui/notification/NotificationMessage.java new file mode 100644 index 0000000..2a1562e --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/notification/NotificationMessage.java @@ -0,0 +1,21 @@ +package com.example.homeutils.system.ui.notification; + +import java.util.Objects; + +public record NotificationMessage( + NotificationLevel level, + String title, + String message) { + + public NotificationMessage { + level = Objects.requireNonNull(level, "level"); + title = Objects.requireNonNull(title, "title").trim(); + message = Objects.requireNonNull(message, "message").trim(); + if (title.isEmpty()) { + title = "提示"; + } + if (message.isEmpty()) { + throw new IllegalArgumentException("message must not be blank"); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/shell/NavigationRail.java b/src/main/java/com/example/homeutils/system/ui/shell/NavigationRail.java new file mode 100644 index 0000000..52f30c3 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/shell/NavigationRail.java @@ -0,0 +1,281 @@ +package com.example.homeutils.system.ui.shell; + +import com.example.homeutils.system.ui.DesignTokens; +import com.example.homeutils.system.ui.MainWindow; +import com.example.homeutils.system.ui.Theme; +import com.example.homeutils.system.ui.icon.AppIcon; +import com.example.homeutils.system.ui.icon.IconFactory; +import javafx.application.Platform; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyBooleanWrapper; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ContentDisplay; +import javafx.scene.control.Separator; +import javafx.scene.control.ToggleButton; +import javafx.scene.control.ToggleGroup; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; + +/** + * Application-level navigation with a fixed expanded or collapsed width. + */ +public final class NavigationRail extends VBox { + private static final PseudoClass COLLAPSED = PseudoClass.getPseudoClass("collapsed"); + + private final ReadOnlyBooleanWrapper expanded = new ReadOnlyBooleanWrapper(this, "expanded", true); + private final Consumer navigator; + private final ToggleGroup selection = new ToggleGroup(); + private final List itemViews = new ArrayList<>(); + private final Map viewsByPluginId = new LinkedHashMap<>(); + private final VBox itemBox = new VBox(); + private final VBox utilityBox = new VBox(); + private final ToggleButton settingsButton = new ToggleButton(); + private final Button collapseButton = new Button(); + + private MainWindow.NavItem selectedItem; + private String selectedPluginId; + + public NavigationRail(List items, + Consumer navigator, + Runnable openSettings) { + requireFxThread(); + this.navigator = Objects.requireNonNull(navigator, "navigator must not be null"); + Objects.requireNonNull(openSettings, "openSettings must not be null"); + + getStyleClass().addAll(Theme.CSS_NAV_BAR, "navigation-rail"); + itemBox.getStyleClass().add("navigation-rail-items"); + VBox.setVgrow(itemBox, Priority.ALWAYS); + buildSettingsEntry(openSettings); + buildBottomToggle(); + + utilityBox.setId("navigation-rail-tools"); + utilityBox.getStyleClass().add("navigation-rail-tools"); + utilityBox.getChildren().addAll(settingsButton, collapseButton); + + Separator separator = new Separator(); + separator.setId("navigation-rail-separator"); + separator.getStyleClass().add(Theme.CSS_NAV_SEP); + separator.setPadding(Insets.EMPTY); + separator.setMinHeight(1); + separator.setPrefHeight(1); + separator.setMaxHeight(1); + separator.setFocusTraversable(false); + getChildren().addAll(itemBox, separator, utilityBox); + + expanded.addListener((observable, oldValue, newValue) -> updateAppearance()); + setItems(items); + updateAppearance(); + } + + public ReadOnlyBooleanProperty expandedProperty() { + return expanded.getReadOnlyProperty(); + } + + public boolean isExpanded() { + return expanded.get(); + } + + public void setExpanded(boolean value) { + requireFxThread(); + expanded.set(value); + } + + public void toggle() { + requireFxThread(); + setExpanded(!isExpanded()); + } + + public void selectPlugin(String pluginId) { + requireFxThread(); + if (pluginId == null) { + return; + } + ItemView view = viewsByPluginId.get(pluginId); + if (view != null) { + activate(view.item(), view.button()); + } + } + + public String selectedPluginId() { + return selectedPluginId; + } + + public void clearSelection() { + requireFxThread(); + selection.selectToggle(null); + itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + settingsButton.getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE); + selectedItem = null; + selectedPluginId = null; + } + + public void selectSettings() { + requireFxThread(); + selection.selectToggle(settingsButton); + itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + if (!settingsButton.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) { + settingsButton.getStyleClass().add(Theme.CSS_NAV_CELL_ACTIVE); + } + selectedItem = null; + selectedPluginId = null; + } + + public void setItems(List items) { + requireFxThread(); + Objects.requireNonNull(items, "items must not be null"); + + selection.selectToggle(null); + for (ItemView view : itemViews) { + view.button().setOnAction(null); + view.button().setTooltip(null); + view.button().setToggleGroup(null); + } + itemBox.getChildren().clear(); + itemViews.clear(); + viewsByPluginId.clear(); + selectedItem = null; + selectedPluginId = null; + + for (int index = 0; index < items.size(); index++) { + addItem(Objects.requireNonNull(items.get(index), "navigation item must not be null"), index); + } + updateItemAppearance(); + } + + Tooltip tooltipFor(String pluginId) { + ItemView view = pluginId == null ? null : viewsByPluginId.get(pluginId); + return view == null ? null : view.tooltip(); + } + + private void buildSettingsEntry(Runnable openSettings) { + settingsButton.setId("navigation-rail-settings"); + settingsButton.getStyleClass().addAll(Theme.CSS_NAV_CELL, "navigation-rail-settings"); + settingsButton.setToggleGroup(selection); + settingsButton.setContentDisplay(ContentDisplay.LEFT); + settingsButton.setGraphicTextGap(Theme.S3); + settingsButton.setMinHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setPrefHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setMaxHeight(DesignTokens.ROW_HEIGHT); + settingsButton.setMaxWidth(Double.MAX_VALUE); + settingsButton.setAccessibleText("系统设置"); + settingsButton.setFocusTraversable(true); + settingsButton.setOnAction(event -> { + selectSettings(); + openSettings.run(); + }); + } + + private void buildBottomToggle() { + collapseButton.setId("navigation-rail-toggle"); + collapseButton.getStyleClass().addAll(Theme.CSS_NAV_CELL, "navigation-rail-toggle"); + collapseButton.setContentDisplay(ContentDisplay.LEFT); + collapseButton.setGraphicTextGap(Theme.S3); + collapseButton.setMinHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setPrefHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setMaxHeight(DesignTokens.ROW_HEIGHT); + collapseButton.setMaxWidth(Double.MAX_VALUE); + collapseButton.setFocusTraversable(true); + collapseButton.setOnAction(event -> toggle()); + } + + private void addItem(MainWindow.NavItem item, int index) { + ToggleButton button = new ToggleButton(item.name, IconFactory.createButtonIcon(item.icon, item.name)); + button.setToggleGroup(selection); + button.setContentDisplay(ContentDisplay.LEFT); + button.setGraphicTextGap(Theme.S3); + button.setAlignment(Pos.CENTER_LEFT); + button.setMinHeight(DesignTokens.ROW_HEIGHT); + button.setPrefHeight(DesignTokens.ROW_HEIGHT); + button.setMaxHeight(DesignTokens.ROW_HEIGHT); + button.setMaxWidth(Double.MAX_VALUE); + button.setAccessibleText(item.name); + button.setFocusTraversable(true); + button.getStyleClass().add(Theme.CSS_NAV_CELL); + if (item.pluginId != null) { + button.setId("nav-" + item.pluginId); + } else { + button.setId("nav-item-" + index); + } + + Tooltip tooltip = new Tooltip(item.name); + button.setOnAction(event -> activate(item, button)); + + ItemView view = new ItemView(item, button, tooltip); + itemViews.add(view); + if (item.pluginId != null) { + viewsByPluginId.put(item.pluginId, view); + } + itemBox.getChildren().add(button); + } + + private void activate(MainWindow.NavItem item, ToggleButton button) { + selection.selectToggle(button); + settingsButton.getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE); + itemViews.forEach(view -> view.button().getStyleClass().remove(Theme.CSS_NAV_CELL_ACTIVE)); + if (!button.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)) { + button.getStyleClass().add(Theme.CSS_NAV_CELL_ACTIVE); + } + if (selectedItem == item) { + return; + } + selectedItem = item; + selectedPluginId = item.pluginId; + navigator.accept(item); + } + + private void updateAppearance() { + int width = isExpanded() + ? DesignTokens.NAV_EXPANDED_WIDTH + : DesignTokens.NAV_COLLAPSED_WIDTH; + setMinWidth(width); + setPrefWidth(width); + setMaxWidth(width); + pseudoClassStateChanged(COLLAPSED, !isExpanded()); + + String collapseLabel = isExpanded() ? "收起" : "展开"; + String accessibleText = collapseLabel + "导航栏"; + collapseButton.setGraphic(IconFactory.createButtonIcon( + isExpanded() ? AppIcon.CHEVRON_LEFT : AppIcon.CHEVRON_RIGHT, + accessibleText)); + collapseButton.setText(isExpanded() ? collapseLabel : null); + collapseButton.setAccessibleText(accessibleText); + collapseButton.setTooltip(new Tooltip(accessibleText)); + collapseButton.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); + settingsButton.setGraphic(IconFactory.createButtonIcon(AppIcon.SETTINGS, "系统设置")); + settingsButton.setText(isExpanded() ? "系统设置" : null); + settingsButton.setTooltip(isExpanded() ? null : new Tooltip("系统设置")); + settingsButton.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); + updateItemAppearance(); + } + + private void updateItemAppearance() { + for (ItemView view : itemViews) { + MainWindow.NavItem item = view.item(); + ToggleButton button = view.button(); + button.setText(isExpanded() ? item.name : null); + button.setTooltip(isExpanded() ? null : view.tooltip()); + button.setAccessibleText(item.name); + button.setAlignment(isExpanded() ? Pos.CENTER_LEFT : Pos.CENTER); + } + } + + private record ItemView(MainWindow.NavItem item, ToggleButton button, Tooltip tooltip) { + } + + private static void requireFxThread() { + if (!Platform.isFxApplicationThread()) { + throw new IllegalStateException("NavigationRail must be accessed on the JavaFX Application Thread"); + } + } +} diff --git a/src/main/java/com/example/homeutils/system/ui/shell/PageHeader.java b/src/main/java/com/example/homeutils/system/ui/shell/PageHeader.java new file mode 100644 index 0000000..4fc9bc5 --- /dev/null +++ b/src/main/java/com/example/homeutils/system/ui/shell/PageHeader.java @@ -0,0 +1,31 @@ +package com.example.homeutils.system.ui.shell; + +import com.example.homeutils.system.ui.Theme; +import javafx.scene.control.Label; +import javafx.scene.layout.VBox; + +import java.util.Objects; + +/** Semantic title block for plugin content pages. */ +public final class PageHeader extends VBox { + + public PageHeader(String title) { + this(title, null); + } + + public PageHeader(String title, String description) { + super(Theme.S1); + + Label titleLabel = new Label(Objects.requireNonNull(title, "title must not be null")); + titleLabel.setFont(Theme.pageTitle()); + titleLabel.getStyleClass().add(Theme.CSS_PAGE_TITLE); + getChildren().add(titleLabel); + + if (description != null && !description.isBlank()) { + Label descriptionLabel = new Label(description); + descriptionLabel.setFont(Theme.secondary()); + descriptionLabel.getStyleClass().add(Theme.CSS_SECONDARY_TEXT); + getChildren().add(descriptionLabel); + } + } +} diff --git a/src/main/resources/app_icon.ico b/src/main/resources/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d6b5568f0d0e167a5736ea1c7bd20a5c024ba214 GIT binary patch literal 41086 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�I7w@} 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 IyMNyOf1FlLH~;_u literal 0 HcmV?d00001 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..ef4323a --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,25 @@ +spring: + application: + name: homeutils + +homeutils: + # base dir, empty = defaults to the HomeUtils application home + base-directory: "" + # export dir, empty = defaults to {application-home}/export (auto-created on startup) + export-directory: "" + # data file paths (relative -> under application-home/data/, absolute -> as-is) + project-paths-file: "data/project_paths.txt" + damaged-repos-file: "data/damaged_repos.cache" + datasources-file: "data/datasources.json" + homeutils-config-file: "data/homeutils.config" + mybatis-generator-file: "data/mybatis-generator.properties" + plugins-config-file: "data/plugins.config" + +logging: + charset: + console: UTF-8 + level: + com.example.homeutils: INFO + org.springframework: WARN + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" diff --git a/src/main/resources/css/light-theme.css b/src/main/resources/css/light-theme.css new file mode 100644 index 0000000..f0d0089 --- /dev/null +++ b/src/main/resources/css/light-theme.css @@ -0,0 +1,634 @@ +/* ============================================================ + HomeUtils Light Theme — 基于 AtlantaFX PrimerLight + 定义所有项目自定义样式类,覆盖 AtlantaFX 默认 + ============================================================ */ + +/* ---- 根变量 ---- */ +.root { + -homeutils-bg: #f8fafc; + -homeutils-surface: #ffffff; + -homeutils-card: #f1f5f9; + -homeutils-border: #e2e8f0; + -homeutils-text: #1e293b; + -homeutils-text-secondary: #64748b; + -homeutils-text-muted: #94a3b8; + -homeutils-primary: #2563eb; + -homeutils-danger: #ef4444; + -homeutils-warning: #f59e0b; + -homeutils-success: #10b981; + + -homeutils-primary-light: #dbeafe; + -fx-font-family: "Segoe UI"; + -fx-font-size: 14px; +} + +/* ---- 导航栏 ---- */ +.nav-bar { + -fx-background-color: -homeutils-surface; + -fx-border-color: -homeutils-border; + -fx-border-width: 0 1 0 0; +} + +.nav-separator { + -fx-padding: 0; + -fx-min-height: 1px; + -fx-pref-height: 1px; + -fx-max-height: 1px; + -fx-background-color: transparent; +} + +.nav-separator .line { + -fx-border-color: -homeutils-border transparent transparent transparent; + -fx-border-width: 1px 0 0 0; + -fx-border-insets: 0; + -fx-padding: 0; +} + +/* ============ ToggleButton 导航 ============ */ +.navigation-rail-items { + -fx-padding: 8px 0; +} + +.nav-cell { + -fx-background-color: transparent; + -fx-background-radius: 0; + -fx-background-insets: 0; + -fx-border-color: transparent; + -fx-border-width: 0 0 0 3px; + -fx-border-radius: 0; + -fx-padding: 0 14px 0 11px; + -fx-text-fill: -homeutils-text-secondary; + -fx-font-size: 14px; + -fx-font-weight: 500; + -fx-graphic-text-gap: 12px; + -fx-cursor: hand; +} + +.nav-cell .app-icon { + -fx-icon-color: -homeutils-text-secondary; +} + +.nav-cell:hover { + -fx-background-color: -homeutils-card; +} + +.nav-cell:selected, +.nav-cell-active { + -fx-background-color: -homeutils-primary-light; + -fx-border-color: -homeutils-primary; + -fx-border-width: 0 0 0 3px; + -fx-text-fill: -homeutils-primary; + -fx-font-weight: 600; +} + +.nav-cell:selected .app-icon, +.nav-cell-active .app-icon { + -fx-icon-color: -homeutils-primary; +} + +.nav-cell:pressed { + -fx-background-color: -homeutils-primary-light; +} + +.nav-cell:focused { + -fx-border-color: -homeutils-primary; + -fx-border-width: 1px 1px 1px 3px; +} + +.nav-version { + -fx-text-fill: -homeutils-text-muted; + -fx-padding: 10px 14px; +} + +/* ---- 顶栏 ---- */ +.header-bar { + -fx-background-color: -homeutils-surface; + -fx-border-width: 0 0 1 0; + -fx-border-color: -homeutils-border; + -fx-pref-height: 48px; + -fx-min-height: 48px; + -fx-max-height: 48px; +} + +.app-brand-icon { + -fx-alignment: center; + -fx-background-color: -homeutils-primary; + -fx-background-radius: 6px; + -fx-min-width: 32px; + -fx-pref-width: 32px; + -fx-max-width: 32px; + -fx-min-height: 32px; + -fx-pref-height: 32px; + -fx-max-height: 32px; +} + +.app-brand-icon .app-icon { + -fx-icon-color: white; +} + +/* ---- 内容区 ---- */ +.content-area { + -fx-background-color: -homeutils-bg; +} + +/* ---- 卡片 ---- */ +.card { + -fx-background-color: -homeutils-surface; + -fx-background-radius: 6; + -fx-border-color: -homeutils-border; + -fx-border-radius: 6; + -fx-border-width: 1; +} + +.page-title { + -fx-font-size: 20px; + -fx-font-weight: 600; + -fx-text-fill: -homeutils-text; +} + +.body-text { + -fx-font-size: 14px; + -fx-text-fill: -homeutils-text; +} + +.secondary-text { + -fx-font-size: 13px; + -fx-text-fill: -homeutils-text-secondary; +} + +.app-icon { + -fx-icon-color: -homeutils-text-secondary; +} + +.control-default, +.text-field-default, +.combo-default { + -fx-pref-height: 36px; + -fx-min-height: 36px; +} + +/* ---- 按钮增强 ---- */ +.btn-primary { + -fx-background-color: -homeutils-primary; + -fx-text-fill: white; + -fx-font-weight: bold; + -fx-background-radius: 4; + -fx-cursor: hand; +} + +.btn-primary:hover { + -fx-background-color: derive(-homeutils-primary, -12%); +} + +.btn-primary:disabled:running { + -fx-opacity: 1; + -fx-background-color: -homeutils-primary; + -fx-text-fill: white; +} + +.btn-primary:disabled:running .loading-spinner { + -fx-progress-color: white; + -fx-opacity: 1; +} + +.btn-danger { + -fx-background-color: -homeutils-danger; + -fx-text-fill: white; + -fx-background-radius: 4; + -fx-cursor: hand; +} + +.btn-danger:hover { + -fx-background-color: derive(-homeutils-danger, -12%); +} + +.btn-success { + -fx-background-color: -homeutils-success; + -fx-text-fill: white; + -fx-background-radius: 4; + -fx-cursor: hand; +} + +.btn-warning { + -fx-background-color: -homeutils-warning; + -fx-text-fill: white; + -fx-background-radius: 4; + -fx-cursor: hand; +} + +.btn-small { + -fx-background-color: -homeutils-card; + -fx-text-fill: -homeutils-text-secondary; + -fx-background-radius: 4; + -fx-padding: 4 12 4 12; + -fx-cursor: hand; +} + +.btn-small:hover { + -fx-background-color: -homeutils-border; +} + +/* ---- 文本域 ---- */ +.text-field-default { + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-padding: 6 10; +} + +.text-area-default { + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-background-insets: 0; + -fx-padding: 8; +} + +/* ---- 日志区 ---- */ +.log-area { + -fx-control-inner-background: #f8fafc; + -fx-text-fill: -homeutils-text; + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-background-radius: 4; + -fx-font-family: "Consolas"; + -fx-font-size: 13px; +} + +/* ---- 状态栏 ---- */ +.status-bar { + -fx-background-color: -homeutils-card; + -fx-border-width: 1 0 0 0; + -fx-border-color: -homeutils-border; + -fx-text-fill: -homeutils-text-secondary; + -fx-padding: 8 12 8 12; +} + +/* ---- DB 信息栏 ---- */ +.db-info-bar { + -fx-background-color: derive(#2563eb, 90%); + -fx-background-radius: 6; +} + +.db-info-box { + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-background-color: white; + -fx-background-radius: 4; +} + +/* ---- 表格 ---- */ +.table-default { + -fx-background-color: white; + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-fixed-cell-size: 40px; + -fx-font-size: 13px; +} + +/* ---- 标题 ---- */ +.section-title { + -fx-font-size: 15px; + -fx-font-weight: 600; + -fx-text-fill: -homeutils-text; +} + +.hint-text { + -fx-text-fill: -homeutils-text-secondary; + -fx-font-size: 13px; +} + +.muted-text { + -fx-text-fill: -homeutils-text-muted; + -fx-font-size: 13px; +} + +.danger-text { + -fx-text-fill: -homeutils-danger; +} + +.warning-text { + -fx-text-fill: -homeutils-warning; +} + +/* ---- 欢迎页 ---- */ +.welcome-page { + -fx-background-color: -homeutils-bg; +} + +.welcome-heading { + -fx-font-weight: bold; + -fx-text-fill: -homeutils-text; +} + +.welcome-desc { + -fx-text-fill: -homeutils-text-secondary; +} + +/* ---- 分割线 ---- */ +.divider { + -fx-background-color: -homeutils-border; +} + +/* ---- 切换按钮组 ---- */ +.toggle-btn { + -fx-background-radius: 4; + -fx-cursor: hand; + -fx-padding: 6 16; +} + +.toggle-btn:selected { + -fx-background-color: -homeutils-primary; + -fx-text-fill: white; +} + +.toggle-btn:not(:selected) { + -fx-background-color: -homeutils-card; + -fx-text-fill: -homeutils-text; + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; +} + +/* ---- ComboBox ---- */ +.combo-default { + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-background-radius: 4; +} + +/* ---- 项目树 ---- */ +.project-tree { + -fx-background-color: white; + -fx-border-color: -homeutils-border; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-fixed-cell-size: 40px; + -fx-font-size: 13px; +} + +/* ---- 代码文本 ---- */ +.mono-text { + -fx-font-family: "Consolas"; +} + +/* ---- 主题切换按钮 ---- */ +.theme-toggle-row { + -fx-padding: 8 0; + -fx-alignment: CENTER_LEFT; +} + +/* ---- 紧凑列表单元格 ---- */ +.compact-list .list-cell { + -fx-padding: 3 8 3 8; + -fx-font-size: 13px; +} + +.compact-list .list-cell:selected { + -fx-background-color: derive(-homeutils-primary, 85%); + -fx-text-fill: -homeutils-text; +} + +.compact-list .list-cell:filled:hover { + -fx-background-color: -homeutils-card; +} +/* ---- TabPane 样式(API 导出界面) ---- */ +.apidoc-tab-pane { + -fx-background-color: transparent; + -fx-padding: 0; +} +.apidoc-tab-pane .tab-header-area { + -fx-background-color: transparent; + -fx-padding: 0 0 8 0; +} +.apidoc-tab-pane .tab-header-background { + -fx-background-color: transparent; + -fx-border-color: transparent; +} +.apidoc-tab-pane .tab { + -fx-background-color: transparent; + -fx-background-radius: 0; + -fx-border-color: transparent; + -fx-border-width: 0 0 2 0; + -fx-padding: 6 16 6 16; + -fx-cursor: hand; +} +.apidoc-tab-pane .tab .tab-label { + -fx-text-fill: -homeutils-text-secondary; + -fx-font-size: 14px; + -fx-font-weight: normal; +} +.apidoc-tab-pane .tab:selected { + -fx-border-color: -homeutils-primary; + -fx-border-width: 0 0 2 0; +} +.apidoc-tab-pane .tab:selected .tab-label { + -fx-text-fill: -homeutils-primary; + -fx-font-weight: bold; +} +.apidoc-tab-pane .tab:hover:not(:selected) { + -fx-background-color: rgba(37,99,235,0.05); +} +.apidoc-tab-pane .tab-content-area { + -fx-background-color: transparent; + -fx-padding: 0; +} + +.operation-status { + -fx-padding: 6 0; +} + +.operation-status-success .label { + -fx-text-fill: #059669; +} + +.operation-status-error .label { + -fx-text-fill: #dc2626; +} + +.empty-state { + -fx-padding: 28 20; + -fx-background-color: #f8fafc; + -fx-background-radius: 8; +} + +.empty-state-title { + -fx-text-fill: #1e293b; +} + +.empty-state-description { + -fx-text-fill: #64748b; +} + +/* ============================================================ + 微信 AI 助手(wechat-ai 插件) + ============================================================ */ + +/* ---- 顶部工具栏 ---- */ +.wx-toolbar { + -fx-background-color: -homeutils-surface; + -fx-border-color: -homeutils-border; + -fx-border-width: 0 0 1 0; + -fx-padding: 10 16; + -fx-spacing: 12; + -fx-alignment: CENTER_LEFT; +} + +.wx-toolbar .label { + -fx-text-fill: -homeutils-text-secondary; +} + +.wx-toolbar .separator .line { + -fx-border-color: -homeutils-border; +} + +/* ---- 状态徽标 ---- */ +.wx-badge { + -fx-background-color: -homeutils-card; + -fx-background-radius: 999; + -fx-border-color: -homeutils-border; + -fx-border-radius: 999; + -fx-border-width: 1; + -fx-padding: 3 10 3 6; + -fx-spacing: 5; + -fx-alignment: CENTER_LEFT; +} + +.wx-badge-dot { + -fx-min-width: 8px; + -fx-pref-width: 8px; + -fx-max-width: 8px; + -fx-min-height: 8px; + -fx-pref-height: 8px; + -fx-max-height: 8px; + -fx-background-radius: 999; +} + +.wx-badge-dot.online { -fx-background-color: #10b981; } +.wx-badge-dot.offline { -fx-background-color: #94a3b8; } +.wx-badge-dot.error { -fx-background-color: #ef4444; } +.wx-badge-dot.warning { -fx-background-color: #f59e0b; } + +.wx-badge-text { + -fx-text-fill: -homeutils-text-secondary; + -fx-font-size: 12px; +} + +/* ---- 聊天记录 ---- */ +.wx-chat-list { + -fx-background-color: transparent; + -fx-border-color: transparent; + -fx-padding: 0; + -fx-fixed-cell-size: -1; +} + +.wx-chat-list .list-cell { + -fx-background-color: transparent; + -fx-padding: 6 8; +} + +.wx-bubble-row { + -fx-alignment: CENTER_LEFT; + -fx-spacing: 6; + -fx-padding: 2 0; +} + +.wx-bubble-row.sender-row { + -fx-alignment: CENTER; + -fx-padding: 6 0 2 0; +} + +.wx-sender { + -fx-text-fill: -homeutils-text-muted; + -fx-font-size: 11px; +} + +.wx-time { + -fx-text-fill: -homeutils-text-muted; + -fx-font-size: 11px; + -fx-alignment: CENTER; +} + +.wx-bubble { + -fx-background-color: -homeutils-card; + -fx-background-radius: 10; + -fx-padding: 6 10; + -fx-text-fill: -homeutils-text; + -fx-font-size: 13px; + -fx-wrap-text: true; +} + +.wx-bubble.mine { + -fx-background-color: derive(#2563eb, 88%); + -fx-text-fill: -homeutils-text; +} + +.wx-bubble-row.mine-row { + -fx-alignment: CENTER_RIGHT; +} + +/* ---- AI 草稿卡 ---- */ +.wx-draft-card { + -fx-background-color: derive(#2563eb, 93%); + -fx-background-radius: 8; + -fx-border-color: derive(#2563eb, 70%); + -fx-border-radius: 8; + -fx-border-width: 1; + -fx-padding: 10 12; + -fx-spacing: 8; +} + +.wx-draft-card .label { + -fx-text-fill: -homeutils-text; +} + +/* ---- 日志着色 ---- */ +.wx-log-info { + -fx-text-fill: -homeutils-text-secondary; +} + +.wx-log-warn { + -fx-text-fill: #b45309; +} + +.wx-log-error { + -fx-text-fill: #dc2626; +} + +/* ---- 指标标签 ---- */ +.wx-metric-label { + -fx-text-fill: -homeutils-text-muted; + -fx-font-size: 11px; +} + +/* ---- 提示行 ---- */ +.wx-hint { + -fx-text-fill: -homeutils-text-muted; + -fx-font-size: 12px; +} + +/* ---- 右侧配置面板 ---- */ +.wx-config-tab .tab-header-background { + -fx-background-color: transparent; +} + +.wx-config-tab .tab-content-area { + -fx-padding: 12 4 0 4; +} + +.wx-form-row { + -fx-spacing: 8; + -fx-alignment: CENTER_LEFT; +} + +.wx-form-label { + -fx-text-fill: -homeutils-text-secondary; + -fx-font-size: 13px; + -fx-min-width: 90px; +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..61c5e4a --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,20 @@ + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/mybatis-generator.properties b/src/main/resources/mybatis-generator.properties new file mode 100644 index 0000000..e37c349 --- /dev/null +++ b/src/main/resources/mybatis-generator.properties @@ -0,0 +1,11 @@ +db_driver=com.mysql.jdbc.Driver +db_url=jdbc:mysql://localhost:3306/disburse?useUnicode=true&characterEncoding=utf8&useSSL=false +db_username=root +db_password=1Qaz@4772 + +model_package=model +model_target_project=./generator +sqlmapper_package=mapperxml +sqlmapper_target_project=./generator +dao_package=mapper +dao_target_project=./generator \ No newline at end of file diff --git a/src/main/resources/templates/api_base_classes.json b/src/main/resources/templates/api_base_classes.json new file mode 100644 index 0000000..c3fa05c --- /dev/null +++ b/src/main/resources/templates/api_base_classes.json @@ -0,0 +1,45 @@ +[ + { + "fqn": "com.bestpay.drip.commons.support.BaseDTO", + "fields": [ + { "name": "reqId", "type": "String", "javadoc": "请求流水号(50位)", "annotations": ["@NotBlank(message=\"reqID不能为空\")"] }, + { "name": "instId", "type": "String", "javadoc": "指令流水号,业务幂等性使用", "annotations": [] }, + { "name": "ext", "type": "Map", "javadoc": "扩展字段", "annotations": [] }, + { "name": "optUser", "type": "String", "javadoc": "操作人", "annotations": [] } + ] + }, + { + "fqn": "com.bestpay.cf.codeutils.common.base.BaseReqDTO", + "fields": [ + { "name": "requestSystem", "type": "String", "javadoc": "请求系统标识", "annotations": ["@NotBlank"] }, + { "name": "traceLogId", "type": "String", "javadoc": "链路日志ID", "annotations": ["@NotBlank"] } + ] + }, + { + "fqn": "cn.com.bestpay.Response", + "fields": [ + { "name": "success", "type": "boolean", "javadoc": "是否成功", "annotations": [] }, + { "name": "result", "type": "T", "javadoc": "返回结果(泛型内层数据)", "annotations": [] }, + { "name": "errorCode", "type": "String", "javadoc": "错误码", "annotations": [] }, + { "name": "errorMsg", "type": "String", "javadoc": "错误信息", "annotations": [] }, + { "name": "primaryErrorCode", "type": "String", "javadoc": "主错误码", "annotations": [] }, + { "name": "primaryErrorMsg", "type": "String", "javadoc": "主错误信息", "annotations": [] }, + { "name": "primaryErrorIP", "type": "String", "javadoc": "主错误IP", "annotations": [] } + ] + }, + { + "fqn": "com.bestpay.drip.commons.support.Result", + "fields": [ + { "name": "t", "type": "T", "javadoc": "返回结果(泛型内层数据)", "annotations": [] }, + { "name": "errCd", "type": "String", "javadoc": "错误码 8位 = 2位中台域+2位系统域+4位错误码", "annotations": [] }, + { "name": "errMsg", "type": "String", "javadoc": "错误描述", "annotations": [] }, + { "name": "suggestStrategy", "type": "String", "javadoc": "错误处理建议", "annotations": [] }, + { "name": "errLocation", "type": "String", "javadoc": "错误位置信息 class#method:line", "annotations": [] }, + { "name": "primaryErrIP", "type": "String", "javadoc": "原始错误IP", "annotations": [] }, + { "name": "primaryErrLocation", "type": "String", "javadoc": "原始错误位置信息 class#method:line", "annotations": [] }, + { "name": "primaryErrCd", "type": "String", "javadoc": "原始错误码 8位 = 2位中台域+2位系统域+4位错误码", "annotations": [] }, + { "name": "primaryErrMsg", "type": "String", "javadoc": "原始错误描述", "annotations": [] }, + { "name": "errCdTrace", "type": "String", "javadoc": "错误码链路,逗号分割。8位为一个元素", "annotations": [] } + ] + } +] diff --git a/src/main/resources/templates/apidoc/api-doc-snapshot.js b/src/main/resources/templates/apidoc/api-doc-snapshot.js new file mode 100644 index 0000000..8351cab --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc-snapshot.js @@ -0,0 +1,137 @@ +/* ============================================================ + * 导出调试快照 — 生成独立 HTML,可离线打开继续调试 + * ============================================================ */ +function exportDebugSnapshot() { + var card = getActiveDubboCard(); + if (!card) { showToast('⚠ 未找到活动接口'); return; } + var iface = escHtml(card.getAttribute('data-dubbo-interface') || ''); + var method = escHtml(card.getAttribute('data-dubbo-method') || ''); + var paramTypes = card.getAttribute('data-dubbo-param-types') || '[]'; + var sampleArgs = card.getAttribute('data-dubbo-sample-args') || '[{}]'; + + var gwUrl = escHtml(((document.getElementById('dubboGwUrl') || {}).value || getGwUrl())); + var tntId = escHtml(((document.getElementById('dubboTntId') || {}).value || '0101')); + var env = escHtml(((document.getElementById('dubboEnv') || {}).value || 'DEV')); + var appId = escHtml(((document.getElementById('dubboAppId') || {}).value || '980010000114002')); + var evtEnv = escHtml(((document.getElementById('dubboEvtEnv') || {}).value || '')); + var args = (document.getElementById('dubboArgs') || {}).value || sampleArgs; + var respStatus = escHtml(((document.querySelector('.dubbo-response-status') || {}).textContent || '')); + var respTime = escHtml(((document.querySelector('.dubbo-response-content-time') || {}).textContent || '')); + var respBody = escHtml(((document.getElementById('dubboRespContent') || {}).textContent || '')); + + var proxy = (typeof DUBBO_PROXY_URL !== 'undefined') ? DUBBO_PROXY_URL : ''; + + var now = new Date(); + var ts = now.getFullYear() + pad2(now.getMonth()+1) + pad2(now.getDate()) + pad2(now.getHours()) + pad2(now.getMinutes()) + pad2(now.getSeconds()); + + var html = buildSnapshotHtml(iface, method, paramTypes, sampleArgs, gwUrl, tntId, env, appId, evtEnv, args, respStatus, respTime, respBody, proxy, ts); + + var blob = new Blob([html], { type: 'text/html;charset=utf-8' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'debug_snapshot_' + method + '_' + ts + '.html'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showToast('✅ 快照已导出 — ' + method); +} + +function buildSnapshotHtml(iface, method, paramTypes, sampleArgs, gwUrl, tntId, env, appId, evtEnv, args, respStatus, respTime, respBody, proxy, ts) { + // 收集所有 JS 代码,并转义闭合标签防止浏览器提前关闭 script 标签 + var snapJs = 'DUBBO_PROXY_URL = ' + JSON.stringify(proxy) + ';\n' + + '// 快照硬编码接口信息(替代原 getActiveDubboCard)\n' + + 'var SNAP_IFACE = ' + JSON.stringify(iface) + ';\n' + + 'var SNAP_METHOD = ' + JSON.stringify(method) + ';\n' + + 'var SNAP_PARAM_TYPES = ' + JSON.stringify(paramTypes) + ';\n' + + 'var SNAP_SAMPLE_ARGS = ' + JSON.stringify(sampleArgs) + ';\n' + + '\n' + SNAPSHOT_JS(); + // 转义内联 JS 中的 <\/script>、<\/style>、<\/title> 等闭合标签,防止 HTML 解析器提前截断 + snapJs = snapJs.replace(/<\/(s(?:cript|tyle)|title|head|body|html|div|span|pre|textarea|button|select|input|table|tr|td|th|thead|tbody|tfoot|label|form|a|p|h[1-6]|ul|ol|li|br|hr|img|meta|link|code)\b/gi, + function(m) { return '<\\/' + m.substring(2); }); + + return '\n\n\n\nDubbo 调试快照 — ' + method + '\n\n\n\n' + + '

\n' + + '<' + 'script>\n' + snapJs + '\n<' + '/script>\n\n'; +} + +function SNAPSHOT_CSS() { + return '*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;font-size:13px;color:#1d2129;background:#f2f3f5;min-height:100vh}.snap-page{max-width:1280px;margin:0 auto;padding:20px 24px}.snap-header{display:flex;align-items:center;justify-content:space-between;padding:14px 20px;background:#fff;border-radius:8px 8px 0 0;border:1px solid #e5e6eb;border-bottom:none}.snap-title{font-size:16px;font-weight:600}.snap-tag{font-size:12px;color:#86909c}.snap-body{display:flex;gap:0;border:1px solid #e5e6eb;border-top:none;background:#fff;border-radius:0 0 8px 8px;min-height:500px}.snap-left{flex:1;padding:16px 20px;min-width:0;border-right:1px solid #eee}.snap-right{flex:0 0 420px;padding:16px 20px;background:#fafbfc;display:flex;flex-direction:column}.snap-section{margin-bottom:16px}.snap-section-title{font-size:13px;font-weight:600;color:#4e5969;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f2f3f5;display:flex;align-items:center;gap:8px;flex-wrap:wrap}.snap-info-table{width:100%;border-collapse:collapse}.snap-info-table td{padding:4px 8px;border-bottom:1px solid #f7f8fa;font-size:12px}.snap-info-table .snap-label{width:80px;color:#86909c;white-space:nowrap}.snap-info-table .snap-val{word-break:break-all}.mono{font-family:Consolas,Monaco,"Courier New",monospace}.dubbo-field-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;flex-wrap:wrap}.dubbo-field-row label{font-size:12px;color:#86909c;white-space:nowrap;min-width:60px}.dubbo-field-row input,.dubbo-field-row select{border:1px solid #d0d5dd;border-radius:4px;padding:4px 8px;font-size:12px;font-family:inherit;outline:none;background:#fff}.dubbo-field-row input:focus,.dubbo-field-row select:focus{border-color:#165DFF}.dubbo-btn{display:inline-flex;align-items:center;gap:4px;padding:5px 12px;font-size:12px;background:#f2f3f5;border:1px solid #e5e6eb;border-radius:4px;cursor:pointer;color:#4e5969;white-space:nowrap;transition:all .15s}.dubbo-btn:hover{background:#e5e6eb}.dubbo-btn.send{background:#165DFF;color:#fff;border-color:#165DFF;font-weight:500}.dubbo-btn.send:hover{background:#0e42d2}.dubbo-btn.send:disabled{background:#94b5ff;border-color:#94b5ff;cursor:not-allowed}.dubbo-btn.copied{background:#e6ffed!important;border-color:#b7eb8f!important;color:#389e0d!important}.dubbo-response-panel{border:1px solid #e5e6eb;border-radius:8px;overflow:hidden;flex:1;display:flex;flex-direction:column;min-height:0;background:#fff}.dubbo-response-panel-header{padding:8px 14px;font-size:12px;font-weight:600;color:#4e5969;background:#fafafa;border-bottom:1px solid #e5e6eb}.dubbo-response-status{padding:4px 14px;font-size:11px;color:#86909c;border-bottom:1px solid #eee}.dubbo-response-status.ok{color:#389e0d;font-weight:500}.dubbo-response-status.err{color:#cf1322;font-weight:500}.dubbo-response-content-time{padding:2px 14px;font-size:11px;color:#86909c}.dubbo-response-content{flex:1;padding:12px 14px;font-family:Consolas,Monaco,monospace;font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all;overflow:auto;margin:0;color:#1d2129}#dubboToast{position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1d2129;color:#fff;padding:8px 24px;border-radius:6px;font-size:13px;z-index:99999;pointer-events:none;transition:opacity .3s;opacity:0}\n' + + SNAPSHOT_OVERLAY_CSS(); +} + +function SNAPSHOT_OVERLAY_CSS() { + return '.snap-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100000;display:flex;align-items:center;justify-content:center}.snap-overlay-box{background:#fff;border-radius:10px;box-shadow:0 12px 60px rgba(0,0,0,.25);max-width:96vw;max-height:90vh;display:flex;flex-direction:column}.snap-overlay-hdr{padding:14px 20px;border-bottom:1px solid #e5e6eb;display:flex;align-items:center;justify-content:space-between;flex-shrink:0}.snap-overlay-hdr span{font-size:15px;font-weight:600}.snap-overlay-close{background:none;border:none;font-size:22px;cursor:pointer;color:#86909c;padding:0;line-height:1}.snap-overlay-body{padding:16px 20px;flex:1;display:flex;flex-direction:column;min-height:0}.snap-overlay-body .hint{color:#86909c;font-size:12px;margin-bottom:8px;line-height:1.6}.snap-overlay-body textarea{flex:1;min-height:200px;border:1px solid #d0d5dd;border-radius:6px;padding:10px 12px;font-family:Consolas,Monaco,monospace;font-size:12px;resize:vertical;outline:none;line-height:1.6}.snap-overlay-body textarea:focus{border-color:#165DFF}.snap-overlay-ft{padding:14px 20px;border-top:1px solid #e5e6eb;display:flex;gap:10px;flex-shrink:0}.snap-overlay-ft .btn-primary{background:#165DFF;color:#fff;border:none;border-radius:6px;padding:8px 22px;font-size:13px;cursor:pointer}.snap-overlay-ft .btn-secondary{background:#f2f3f5;border:1px solid #d0d5dd;border-radius:6px;padding:8px 22px;font-size:13px;cursor:pointer;color:#4e5969}.snap-overlay-ft .btn-ghost{background:#fff;border:1px solid #d0d5dd;border-radius:6px;padding:8px 22px;font-size:13px;cursor:pointer;color:#4e5969}.snap-compare-wrap{overflow:auto;min-height:0}.snap-compare-table{width:100%;border-collapse:collapse}.snap-compare-table th{text-align:left;padding:8px 10px;font-size:12px;color:#86909c;border-bottom:2px solid #e5e6eb;position:sticky;top:0;background:#fafafa;z-index:1}.snap-compare-table td{padding:5px 10px;font-size:12px;max-width:280px;word-break:break-all;border-bottom:1px solid #f7f8fa}.snap-badge{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;font-weight:500}.snap-badge-changed{background:#fff7e6;color:#d46b08}.snap-badge-sample{background:#e6f7ff;color:#096dd9}.snap-badge-log{background:#f6ffed;color:#389e0d}.snap-badge-same{color:#bbb;font-size:11px}\n'; +} + +function SNAPSHOT_JS() { + // 返回完整的独立调试 JS,覆盖 getActiveDubboCard 以支持脱离原始页面运行 + return 'var DUBBO_GW_URL_KEY="dubbo_gw_url";function getGwUrl(){return localStorage.getItem(DUBBO_GW_URL_KEY)||"https://gw.test.bestpay.net/open/1.0/GenericFacade/genericInvoke"}function setGwUrl(u){localStorage.setItem(DUBBO_GW_URL_KEY,u)}function defaultHeaders(){return{"Content-Type":"application/json","User-Agent":"ApiDoc/1.0","Accept":"*/*"}}function buildEventContext(t,e,n){return JSON.stringify({tntId:t||"0101",env:n||e||"DEV",arNo:"",evtCd:"",pdCd:"",pdPath:""})}function formatLocalDateTime(){var e=new Date,t=pad2;return e.getFullYear()+"-"+t(e.getMonth()+1)+"-"+t(e.getDate())+" "+t(e.getHours())+":"+t(e.getMinutes())+":"+t(e.getSeconds())}function genTraceId(){return"xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g,function(e){var t=16*Math.random()|0;return("x"===e?t:3&t|8).toString(16)})}function getActiveDubboCard(){return{getAttribute:function(e){var t={};t["data-dubbo-interface"]=SNAP_IFACE,t["data-dubbo-method"]=SNAP_METHOD,t["data-dubbo-param-types"]=SNAP_PARAM_TYPES,t["data-dubbo-sample-args"]=SNAP_SAMPLE_ARGS;return t[e]||""}}}function stripJsonComments(e){return e?e.replace(/\\\\"|"(?:\\\\"|[^"])*"|(\\/\\/.*|\\/\\*[\\s\\S]*?\\*\\/)/g,function(e,t){return t?"":e}).replace(/,\\s*\\}/g,"}").replace(/,\\s*\\]/g,"]"):""}function formatJsonPretty(e){try{var t=JSON.parse(stripJsonComments(e));return JSON.stringify(t,null,2)}catch(r){return e}}function formatDubboArgs(){var e=document.getElementById("dubboArgs");e&&(e.value=formatJsonPretty(e.value))}function resetDubboArgs(){var e=document.getElementById("dubboArgs");e&&(e.value=formatJsonPretty(SNAP_SAMPLE_ARGS))}function pad2(e){return e<10?"0"+e:""+e}function escHtml(e){return e?e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/\'/g,"'"):""}function copyToClip(e,t){navigator.clipboard?navigator.clipboard.writeText(e).then(function(){markCopied(t,"已复制")}).catch(function(){fallbackCopy(e,t)}):fallbackCopy(e,t)}function fallbackCopy(e,t){var n=document.createElement("textarea");n.value=e,n.style.position="fixed",n.style.left="-999px",document.body.appendChild(n),n.select();try{document.execCommand("copy"),markCopied(t,"已复制")}catch(r){markCopied(t,"失败")}document.body.removeChild(n)}function markCopied(e,t){if(!e)return;var n=e.textContent;e.textContent=t,e.classList.add("copied"),setTimeout(function(){e.textContent=n,e.classList.remove("copied")},1500)}function showToast(e){var t=document.getElementById("dubboToast");t||((t=document.createElement("div")).id="dubboToast",t.style.cssText="position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1d2129;color:#fff;padding:8px 24px;border-radius:6px;font-size:13px;z-index:99999;pointer-events:none;transition:opacity .3s;opacity:0",document.body.appendChild(t)),t.textContent=e,t.style.opacity="1",clearTimeout(t._tid),t._tid=setTimeout(function(){t.style.opacity="0"},1800)}function onFieldChange(){document.getElementById("dubboGwUrl")&&setGwUrl(document.getElementById("dubboGwUrl").value)}function buildDubboBody(){var e=document.getElementById("dubboArgs"),t=e?e.value:"{}",n=(document.getElementById("dubboTntId")||{}).value||"0101",r=(document.getElementById("dubboEnv")||{}).value||"DEV",o=(document.getElementById("dubboEvtEnv")||{}).value||"DEV",i=(document.getElementById("dubboAppId")||{}).value||"980010000114002",a=getActiveDubboCard(),d=a?a.getAttribute("data-dubbo-interface"):"",c=a?a.getAttribute("data-dubbo-method"):"",l=a?a.getAttribute("data-dubbo-param-types")||"[]":"[]",u;try{u=JSON.parse(stripJsonComments(t))}catch(p){return void alert("args JSON 格式错误: "+p.message)}var g=genTraceId();return{data:{interfacePath:d,method:c,parameterTypes:JSON.parse(l),args:u,traceId:g,zoneType:"G",eventContext:{tntId:n,ipTId:"",ipId:"null",ipRId:"",chlNo:genTraceId(),originalArNo:"",originalEvtCd:"",arNo:"",chlEvtCd:"",prePdCd:"null",preEvtCd:"null",pdCd:"",evtCd:"",pdPath:"",env:o,tagExtensionInfo:"{}",shadow:"0"}},tnt_id:n,app_id:i,timestamp:formatLocalDateTime(),env:r}}function sendDubboRequest(){var e=buildDubboBody();if(!e){showToast("构建请求体失败");return}var t=document.getElementById("dubboSendBtn");t&&(t.disabled=!0,t.textContent="发送中...");var n=document.getElementById("dubboStatus"),r=document.getElementById("dubboRespContent");if(!n||!r)return;n.textContent="发送中...",n.className="dubbo-response-status",r.textContent="";var o=document.querySelector(".dubbo-response-content-time");o&&o.remove();var i=document.getElementById("dubboGwUrl")?document.getElementById("dubboGwUrl").value:getGwUrl(),a=defaultHeaders(),d=(document.getElementById("dubboEvtEnv")||{}).value||"DEV";a["Event-Context"]=buildEventContext(e.tnt_id,e.env,d),a["X-Gateway-Url"]=i,fetch(DUBBO_PROXY_URL,{method:"POST",headers:a,body:JSON.stringify(e)}).then(function(t){var o=t.ok,i=t.status+" "+t.statusText;n.textContent=i,n.className="dubbo-response-status "+(o?"ok":"err");return t.text().then(function(t){var a;try{var d=JSON.parse(t);a=JSON.stringify(d,null,2)}catch(c){a=t}r.textContent=a;var u=document.querySelector(".dubbo-response-content-time");u&&u.remove();var g=\'
\'+formatLocalDateTime()+"
";r.insertAdjacentHTML("beforebegin",g),t&&(t.disabled=!1,t.textContent="\\u25b6 \\u53d1\\u9001\\u8bf7\\u6c42")})}).catch(function(e){var t=e.message||String(e);n.textContent="\\u7f51\\u7edc\\u9519\\u8bef",n.className="dubbo-response-status err",r.textContent=t;var o=document.getElementById("dubboSendBtn");o&&(o.disabled=!1,o.textContent="\\u25b6 \\u53d1\\u9001\\u8bf7\\u6c42")})}function copyDubboCurl(){var e=buildDubboBody();if(!e)return;var t=document.getElementById("dubboGwUrl")?document.getElementById("dubboGwUrl").value:getGwUrl(),n=defaultHeaders(),r=(document.getElementById("dubboEvtEnv")||{}).value||"DEV";n["Event-Context"]=buildEventContext(e.tnt_id,e.env,r);var o="curl -X POST \\\\\\n "+JSON.stringify(t)+" \\\\\\n";for(var i in n)o+=" -H "+JSON.stringify(i+": "+n[i])+" \\\\\\n";o+=" -d "+JSON.stringify(JSON.stringify(e,null,2)),copyToClip(o,null),showToast("cURL \\u5df2\\u590d\\u5236")}function copyDubboBody(){var e=buildDubboBody();if(!e)return;copyToClip(JSON.stringify(e,null,2),null),showToast("\\u8bf7\\u6c42\\u4f53\\u5df2\\u590d\\u5236")}\n' + + SNAPSHOT_LOGEXTRACT_JS() + '\n' + + SNAPSHOT_COMPARE_JS() + '\n' + + SNAPSHOT_ENV_JS() + '\n' + + 'document.addEventListener("DOMContentLoaded",function(){var e=document.getElementById("dubboEnv");if(e){var t=getDefaultEvtEnv(e.value);var n=document.getElementById("dubboEvtEnv");if(n&&!n.value)n.value=t}});\n'; +} + +function SNAPSHOT_LOGEXTRACT_JS() { + return 'function getDefaultEvtEnv(e){return"DEV"===e?"DEV_1156339215001361525":"SIT"===e?"SIT-"+new Date().getFullYear()+pad2(new Date().getMonth()+1)+pad2(new Date().getDate())+"01":""}function onEnvChange(){var e=document.getElementById("dubboEnv"),t=document.getElementById("dubboEvtEnv");e&&t&&(t.value=getDefaultEvtEnv(e.value))}function showLogExtractPopup(){var e=document.getElementById("logExtractOverlay");e&&e.remove();var t=document.createElement("div");t.id="logExtractOverlay",t.className="snap-overlay",t.innerHTML=\'
\'+\'
📋 从日志提取入参
\'+\'
粘贴日志内容,自动识别 接口入参:ClassName(field=value,...) 格式
\'+\'\'+\'
\',document.body.appendChild(t),t.addEventListener("click",function(e){e.target===t&&closeLogExtractPopup()}),setTimeout(function(){var e=document.getElementById("logExtractInput");e&&e.focus()},100)}function closeLogExtractPopup(){var e=document.getElementById("logExtractOverlay");e&&e.remove()}function doExtractLogArgs(){var e=document.getElementById("logExtractInput"),t=e&&e.value;if(!t||!t.trim())return void showToast("⚠ 请粘贴日志内容");t=t.trim();for(var n=t,r=["接口入参:","接口入参:","入参:","入参:"],o=0;o=0){n=t.substring(i+r[o].length).trim();break}}try{var a=flattenSuperChain(parseDtoExpr(n)),d={};try{var c=JSON.parse(SNAP_SAMPLE_ARGS);Array.isArray(c)&&c.length>0&&(d=c[0])}catch(u){}closeLogExtractPopup(),showParamComparePopup(d,a)}catch(u){showToast("❌ 解析失败:"+u.message)}}function parseDtoExpr(e){e=e.trim();var t=e.match(/^([\\w.]+)\\s*\\(([\\s\\S]*)\\)$/);if(!t){var n=findOuterParens(e);if(!n)throw new Error("未识别到 DTO 表达式");e=n,t=e.match(/^([\\w.]+)\\s*\\(([\\s\\S]*)\\)$/);if(!t)throw new Error("DTO 格式异常")}var r={},o=splitTopLevel(t[2],",");for(var i=0;i=0){var c=a.substring(0,d).trim(),u=a.substring(d+1).trim();r[c]=parseDtoValue(u)}}}return r}function parseDtoValue(e){e=e.trim();if(!e)return null;if("["===e.charAt(0)&&"]"===e.charAt(e.length-1)){var t=e.substring(1,e.length-1).trim();if(!t)return[];for(var n=splitTopLevel(t,","),r=[],o=0;o=0){var a=o.substring(0,i).trim(),d=o.substring(i+1).trim();t[a]=parseDtoValue(d)}}}return t}function flattenSuperChain(e){if(!e||"object"!=typeof e||Array.isArray(e))return e;var t={};for(var n in e)if(e.hasOwnProperty(n))if("super"===n&&e[n]&&"object"==typeof e[n]&&!Array.isArray(e[n])){var r=flattenSuperChain(e[n]);for(var o in r)r.hasOwnProperty(o)&&(t[o]=r[o])}else t[n]=e[n];return t}function findOuterParens(e){var t=e.match(/([\\w.]+)\\s*\\(/);if(!t)return null;for(var n=t.index,r=n+t[0].length-1,o=1,i=r+1;i变更\':"sample_only"===c.status?\'仅默认\':"log_only"===c.status?\'仅日志\':\'一致\',l="changed"===c.status?"background:#fffbe6":"sample_only"===c.status?"background:#f0f8ff":"log_only"===c.status?"background:#f6ffed":"";var g=void 0===c.sampleVal?\'—\':escHtml(fmtDiffVal(c.sampleVal)),p=void 0===c.logVal?\'—\':escHtml(fmtDiffVal(c.logVal));a+=\'\'+\'\'+u+""+\'\'+escHtml(c.path||"(根)")+""+\'\'+g+""+\'\'+p+""}var s=document.createElement("div");s.id="paramCompareOverlay",s.className="snap-overlay",s.innerHTML=\'
\'+\'
🔄 参数比对 — 默认值 vs 日志值\'+(o>0?\' \'+o+" 项变更":"")+(i>0?\' \'+i+" 项仅默认":"")+"
"+\'
\'+\'
\'+\'\'+\'\'+\"\"+a+\"
状态参数路径默认参考值日志提取值
\"+(0===r.length?\'
无差异
\':"")+\"
\"+\'
\'+\'\'+\'\'+\'\'+\"
\",document.body.appendChild(s),s.addEventListener("click",function(e){e.target===s&&closeParamComparePopup()}),s._sampleObj=e,s._logObj=t,s._diffCount=r.length}function closeParamComparePopup(){var e=document.getElementById("paramCompareOverlay");e&&e.remove()}function applyCompareResult(e){var t=document.getElementById("paramCompareOverlay"),n=t._sampleObj||{},r=t._logObj||{},o;o="merged"===e?deepMerge(n,r):r;var i=JSON.stringify([o],null,2),a=document.getElementById("dubboArgs");a&&(a.value=i,showToast("✅ 参数已填入(共 "+(t._diffCount||0)+" 个字段参与比对)")),closeParamComparePopup()}function deepMerge(e,t){if(!e||"object"!=typeof e)return t;if(!t||"object"!=typeof t)return e;if(Array.isArray(e)||Array.isArray(t))return t;var n={};for(var r in e)e.hasOwnProperty(r)&&(t.hasOwnProperty(r)?n[r]=deepMerge(e[r],t[r]):n[r]=e[r]);for(var o in t)t.hasOwnProperty(o)&&!e.hasOwnProperty(o)&&(n[o]=t[o]);return n}function diffObjects(e,t,n){var r=[],o=e&&"object"==typeof e&&!Array.isArray(e),i=t&&"object"==typeof t&&!Array.isArray(t);if(!o&&!i){var a=e,d=t;return void 0===a&&void 0!==d?r.push({path:n,sampleVal:void 0,logVal:d,status:"log_only"}):void 0===d&&void 0!==a?r.push({path:n,sampleVal:a,logVal:void 0,status:"sample_only"}):eqVal(a,d)||r.push({path:n,sampleVal:a,logVal:d,status:"changed"}),r}if(o!==i)return eqVal(e,t)||r.push({path:n,sampleVal:e,logVal:t,status:"changed"}),r;var c={};for(var u in e)if(e.hasOwnProperty(u)){c[u]=!0;var l=n?n+"."+u:u;r=r.concat(diffObjects(e[u],t?t[u]:void 0,l))}for(var u in t)t.hasOwnProperty(u)&&!c[u]&&(l=n?n+"."+u:u,r.push({path:l,sampleVal:void 0,logVal:t[u],status:"log_only"}));return r}function eqVal(e,t){return e===t||(void 0!==e&&void 0!==t&&("object"==typeof e||"object"==typeof t?JSON.stringify(e)===JSON.stringify(t):String(e)===String(t)))}function fmtDiffVal(e){return void 0===e||null===e?"null":"object"==typeof e?JSON.stringify(e):String(e)}\n'; +} + +function SNAPSHOT_ENV_JS() { + return ''; +} diff --git a/src/main/resources/templates/apidoc/api-doc.css b/src/main/resources/templates/apidoc/api-doc.css new file mode 100644 index 0000000..fa111da --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.css @@ -0,0 +1,239 @@ +/* ============================================================ + * API 接口文档样式 + * 从 ApiDocGenerator.appendCss() 提取,便于独立维护 + * ============================================================ */ + +/* ===== 基础重置 ===== */ +*{margin:0;padding:0;box-sizing:border-box} +body{font-family:'Microsoft YaHei',-apple-system,sans-serif;color:#1d1d1f;background:#f2f3f5;display:flex;flex-direction:column;min-height:100vh} + +/* ===== 顶部栏 ===== */ +.topbar{display:flex;align-items:center;height:48px;background:#fff;border-bottom:1px solid #e5e6eb;padding:0 20px;position:sticky;top:0;z-index:100} +.topbar-brand{font-weight:700;font-size:16px;color:#1d1d1f;margin-right:32px} +.topbar-tabs{display:flex;gap:0} +.tab{padding:12px 20px;font-size:14px;color:#86909c;cursor:pointer;border-bottom:2px solid transparent;transition:.2s} +.tab.active{color:#1677ff;border-bottom-color:#1677ff;font-weight:600} +.topbar-meta{margin-left:auto;font-size:12px;color:#86909c;white-space:nowrap} + +/* ===== 搜索框 ===== */ +.search-input{padding:5px 12px;border:1px solid #e5e6eb;border-radius:4px;font-size:13px;outline:none;width:200px;transition:border .2s,box-shadow .2s;font-family:inherit} +.search-input:focus{border-color:#1677ff;box-shadow:0 0 0 2px rgba(22,119,255,.1)} + +/* ===== 统计摘要栏 ===== */ +.stats-bar{display:flex;align-items:center;gap:20px;padding:6px 20px;background:#fff;border-bottom:1px solid #e5e6eb;font-size:12px;color:#86909c} +.stats-item{display:flex;align-items:center;gap:3px} +.stats-num{font-weight:700;font-size:14px;color:#1677ff} +.stats-actions{margin-left:auto;display:flex;gap:6px} +.ctrl-btn{padding:3px 10px;border:1px solid #e5e6eb;border-radius:3px;background:#fff;color:#4e5969;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit} +.ctrl-btn:hover{border-color:#1677ff;color:#1677ff;background:#e8f3ff} + +/* ===== 布局 ===== */ +.main-layout{display:flex;flex:1} + +/* ===== 侧栏导航 ===== */ +.sidebar{width:280px;min-width:280px;background:#fff;border-right:1px solid #e5e6eb;overflow-y:auto;padding:16px 0;position:sticky;top:78px;height:calc(100vh - 78px)} +.sidebar-title{font-size:14px;font-weight:700;color:#1d1d1f;padding:0 16px 12px;border-bottom:1px solid #f2f3f5;margin-bottom:8px} +.nav-group{margin-bottom:4px} +.nav-group-title{padding:8px 16px;font-size:13px;font-weight:600;color:#4e5969;cursor:pointer;display:flex;align-items:center;gap:6px;user-select:none;transition:.15s} +.nav-group-title:hover{background:#f2f3f5} +.nav-group-title .arrow{font-size:10px;transition:transform .2s;display:inline-block} +.nav-group-title.open .arrow{transform:rotate(90deg)} +.nav-group-title small{font-weight:400;color:#86909c;font-size:11px} +.nav-group-items{overflow:hidden;max-height:0;transition:max-height .3s ease} +.nav-group-title.open+.nav-group-items{max-height:99999px} +.nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px 6px 32px;font-size:12px;color:#4e5969;text-decoration:none;transition:.15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.nav-item:hover,.nav-item.active{color:#1677ff;background:#e8f3ff} +.nav-item-icon{font-size:14px;flex-shrink:0} +.nav-method{padding-left:48px;font-size:11px;gap:6px} +.nav-method-dot{font-size:6px;color:#c9cdd4;flex-shrink:0;line-height:1} +.nav-http-tag{display:inline-block;min-width:28px;text-align:center;font-size:10px;font-weight:600;padding:0 4px;border-radius:2px;line-height:16px;flex-shrink:0} +.nav-http-tag.get{background:#e8f3ff;color:#1677ff} +.nav-http-tag.post{background:#e8ffea;color:#00b42a} +.nav-http-tag.put{background:#fff7e6;color:#ff7d00} +.nav-http-tag.delete{background:#ffece8;color:#f53f3f} +.nav-http-tag.req{background:#f2f3f5;color:#86909c} +.nav-http-tag.rpc{background:#f3e8ff;color:#722ed1} + +/* ===== 内容区 ===== */ +.content{flex:1;padding:24px;overflow-y:auto;max-width:calc(100vw - 280px)} +.project-section{margin-bottom:32px} +.project-title{font-size:22px;font-weight:700;color:#1d1d1f;margin-bottom:16px;padding-bottom:8px;border-bottom:1px solid #e5e6eb} + +/* ===== 接口卡片 ===== */ +.api-card{background:#fff;border-radius:8px;margin-bottom:16px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06)} +.api-header{display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid #f2f3f5} +.api-header-left{display:flex;align-items:center;gap:10px} +.api-name{font-size:16px;font-weight:700;color:#1d1d1f} +.api-header-right{font-size:13px;color:#86909c} +.type-badge{display:inline-block;padding:2px 10px;border-radius:4px;font-size:12px;font-weight:600} +.type-badge.interface{background:#e8f3ff;color:#1677ff} +.type-badge.class{background:#e8ffea;color:#00b42a} + +/* ===== 信息网格 ===== */ +.info-grid{padding:16px 20px;display:grid;grid-template-columns:1fr 1fr;gap:10px} +.info-item{display:flex;align-items:baseline;gap:8px;font-size:13px} +.info-label{color:#86909c;white-space:nowrap;min-width:64px} +.info-value{color:#1d1d1f;word-break:break-all} +.info-value.mono{font-family:'Consolas','Courier New',monospace;font-size:12px;color:#4e5969} + +/* ===== 注解标签 ===== */ +.anno-tag{display:inline-block;padding:1px 8px;border-radius:3px;font-size:11px;margin:1px 3px;background:#fff7e6;color:#d46b08;border:1px solid #ffd591} +.method-anno{background:#f6ffed;color:#389e0d;border-color:#b7eb8f} + +/* ===== 方法列表 ===== */ +.methods-section{padding:0 20px 16px} +.section-label{font-size:14px;font-weight:700;color:#1d1d1f;padding:8px 0;border-top:1px solid #f2f3f5} + +/* ===== 方法卡片 ===== */ +.method-card{border:1px solid #f2f3f5;border-radius:8px;margin:10px 0;overflow:hidden} +.method-header{display:flex;align-items:center;gap:10px;padding:12px 16px;background:#fafbfc;cursor:pointer;user-select:none;transition:background .15s} +.method-header:hover{background:#f2f3f5} +.method-arrow{font-size:10px;transition:transform .2s;color:#86909c} +.method-header.collapsed .method-arrow{transform:rotate(-90deg)} +.method-name{font-size:14px;font-weight:700;color:#1677ff;font-family:'Consolas','Courier New',monospace;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.method-return{font-size:12px;color:#86909c;flex-shrink:0} +.method-return code{color:#00b42a;background:#e8ffea;padding:2px 6px;border-radius:3px;font-size:11px} +.http-badge{display:inline-block;padding:2px 8px;border-radius:3px;font-size:11px;font-weight:700;color:#fff} +.http-badge.get{background:#1677ff} +.http-badge.post{background:#00b42a} +.http-badge.put{background:#ff7d00} +.http-badge.delete{background:#f53f3f} +.http-badge.req{background:#86909c} +.http-badge.rpc{background:#722ed1} + +/* ===== 方法体 ===== */ +.method-body{padding:12px 16px} +.method-header.collapsed+.method-body{display:none} +.method-annotations{margin-bottom:8px} +.method-desc{font-size:13px;color:#4e5969;padding:8px 12px;background:#fafbfc;border-radius:4px;margin-bottom:10px;border-left:3px solid #e5e6eb;white-space:pre-wrap} +.method-signature{padding:10px 14px;background:#1e1e1e;border-radius:6px;margin-bottom:12px;overflow-x:auto;position:relative;display:flex;align-items:center} +.method-signature code{font-family:'Consolas','Courier New',monospace;font-size:13px;color:#d4d4d4;flex:1} +.copy-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);padding:3px 10px;border:1px solid #555;border-radius:3px;background:transparent;color:#999;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit} +.copy-btn:hover{color:#fff;border-color:#888} +.copy-btn.copied{color:#52c41a;border-color:#52c41a} + +/* ===== 复制按钮 ===== */ +.api-copy-btn{padding:4px 12px;border:1px solid #1677ff;border-radius:4px;background:#e8f3ff;color:#1677ff;font-size:12px;cursor:pointer;transition:.15s;font-family:inherit} +.api-copy-btn:hover{background:#1677ff;color:#fff} +.api-copy-btn.copied{background:#52c41a;border-color:#52c41a;color:#fff} +.method-copy-btn{padding:3px 10px;border:1px solid #e5e6eb;border-radius:3px;background:#fff;color:#4e5969;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit;flex-shrink:0} +.method-copy-btn:hover{border-color:#1677ff;color:#1677ff;background:#e8f3ff} +.method-copy-btn.copied{border-color:#52c41a;color:#52c41a;background:#f6ffed} +.api-copy-btn.txt{background:#fff;color:#1677ff} +.api-copy-btn.txt:hover{background:#e8f3ff} +.method-copy-btn.txt{color:#1677ff;border-color:#91caff;background:#f0f7ff} +.method-copy-btn.img{background:#1677ff;color:#fff;border-color:#1677ff} +.method-copy-btn.img:hover{background:#0958d9;border-color:#0958d9} + +/* ===== 参数块 ===== */ +.params-block{margin-bottom:12px} +.params-block-title{font-size:13px;font-weight:600;color:#4e5969;margin-bottom:6px} + +/* ===== DTO 块(可折叠卡片) ===== */ +.dto-block{margin-bottom:10px;border:1px solid #e5e6eb;border-radius:6px;overflow:hidden} +.dto-block-title{padding:8px 12px;background:#fafbfc;font-size:13px;font-weight:600;cursor:pointer;border-left:3px solid #1677ff;user-select:none;display:flex;align-items:center;gap:6px;transition:background .15s} +.dto-block-title:hover{background:#f2f3f5} +.dto-block-title small{font-weight:400;color:#86909c} +.dto-arrow{font-size:10px;transition:transform .2s;color:#86909c} +.dto-block-title.collapsed .dto-arrow{transform:rotate(-90deg)} +.dto-block-body{padding:0} +.dto-block-title.collapsed+.dto-block-body{display:none} + +/* ===== 未配置包装器警告 ===== */ +.dto-block-warn{border-color:#ff7d00;background:#fff7e6} +.dto-title-warn{border-left-color:#ff7d00!important;background:#fff7e6!important;color:#d46b08!important} +.warn-hint{color:#ff7d00!important;font-weight:600;font-size:11px;background:#fff0d6;padding:2px 8px;border-radius:3px;margin-left:6px} + +/* ===== 表格 ===== */ +.data-table{width:100%;border-collapse:collapse;font-size:12px} +.data-table th{background:#fafbfc;text-align:left;padding:7px 10px;border:1px solid #e5e6eb;font-weight:600;color:#4e5969;white-space:nowrap} +.data-table td{padding:7px 10px;border:1px solid #e5e6eb;color:#1d1d1f;vertical-align:top} +.data-table td:first-child{min-width:200px} +.data-table tr:hover td{background:#f7f8fa} +.data-table code{font-family:'Consolas','Courier New',monospace;font-size:11px;background:#f2f3f5;padding:1px 5px;border-radius:2px;color:#1677ff} +.data-table code.field-name{color:#d46b08} + +/* ===== 字段行层级样式 ===== */ +.field-indent{display:inline-block;min-width:0} +.field-expand-toggle{display:inline-block;font-size:10px;color:#86909c;cursor:pointer;width:12px;text-align:center;transition:transform .15s;user-select:none} +.field-expand-toggle.open{transform:rotate(90deg)} +.field-row.d0 td{background:#fff} +.field-row.d1 td{background:#f9fbff} +.field-row.d2 td{background:#f6f9ff} +.field-row.d3 td{background:#f3f6ff} +.field-row.d1 td:first-child{border-left:3px solid #52c41a} +.field-row.d2 td:first-child{border-left:3px solid #fa8c16} +.field-row.d3 td:first-child{border-left:3px solid #722ed1} +.type-complex{color:#722ed1!important;background:#f3e8ff!important} +.type-custom{color:#0958d9!important;background:#e6f4ff!important;border:1px solid #91caff} + +/* ===== 全限定名与继承 ===== */ +.fqn{font-size:11px;color:#86909c;font-family:'Consolas','Courier New',monospace} +.inherit-tag{font-size:11px;color:#86909c;font-style:italic;margin-left:4px} +.inherit-section{border-top:1px dashed #e5e6eb;padding-top:8px;margin-top:8px} +.inherit-header{display:inline-block;font-size:11px;font-weight:600;color:#86909c;padding:2px 8px;background:#f7f8fa;border-radius:3px;margin-bottom:6px} +.inherit-header code{font-size:11px;color:#1677ff;background:#e8f3ff;padding:1px 5px;border-radius:2px} + +/* ===== throws 异常 ===== */ +.throws-block{font-size:12px;color:#f53f3f;padding:6px 0} +.throws-block code{background:#fff2f0;padding:2px 6px;border-radius:2px} + +/* ===== 搜索高亮 ===== */ +.api-card.search-hidden,.nav-item.search-hidden,.nav-group.search-hidden{display:none!important} +.method-card.search-hidden{display:none!important} +.search-highlight{background:#fff3cd;padding:1px 2px;border-radius:2px} + +/* ===== Dubbo 调试按钮 ===== */ +.method-copy-btn.dubbo{background:#722ed1;color:#fff;border-color:#722ed1} +.method-copy-btn.dubbo:hover{background:#531dab} + +/* ===== Dubbo 调试弹窗(全屏双栏) ===== */ +.dubbo-modal-mask{position:fixed;inset:0;background:rgb(0 0 0/40%);z-index:9999;display:flex} +.dubbo-modal{background:#fff;width:100vw;height:100vh;display:flex;flex-direction:column;border-radius:0;box-shadow:none} +.dubbo-modal-header{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid #e5e6eb;flex-shrink:0;background:#fafbfc} +.dubbo-modal-title{font-size:15px;font-weight:700;color:#1d2129} +.dubbo-modal-close{width:28px;height:28px;border:none;background:#f2f3f5;border-radius:4px;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;color:#4e5969} +.dubbo-modal-close:hover{background:#e5e6eb} + +/* 双栏主体 */ +.dubbo-modal-body{flex:1;display:flex;overflow:hidden;min-height:0} +.dubbo-modal-left{flex:1;overflow-y:auto;padding:16px 20px;min-width:0;display:flex;flex-direction:column} +.dubbo-modal-right{width:42%;min-width:360px;max-width:600px;border-left:1px solid #e5e6eb;display:flex;flex-direction:column;background:#fafbfc;overflow:hidden} + +.dubbo-modal-body label{display:block;font-size:13px;font-weight:600;color:#4e5969;margin-bottom:4px} +.dubbo-modal-body label .hint{font-weight:400;color:#86909c} +.dubbo-modal-body input,.dubbo-modal-body textarea,.dubbo-modal-body select{width:100%;padding:8px 12px;border:1px solid #d0d5dd;border-radius:4px;font-size:13px;font-family:'Consolas','Courier New',monospace;box-sizing:border-box} +.dubbo-modal-body textarea{resize:vertical;min-height:120px;line-height:1.5} +.dubbo-modal-body input:focus,.dubbo-modal-body textarea:focus,.dubbo-modal-body select:focus{outline:none;border-color:#1677ff;box-shadow:0 0 0 2px rgb(22 119 255/15%)} +.dubbo-field-row{display:flex;gap:12px;margin-bottom:12px} +.dubbo-field-row>div{flex:1;min-width:0} + +/* 右栏响应区域 */ +.dubbo-response-panel{flex:1;display:flex;flex-direction:column;overflow:hidden} +.dubbo-response-panel-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #e5e6eb;font-size:12px;background:#fff;flex-shrink:0} +.dubbo-response-panel-empty{flex:1;display:flex;align-items:center;justify-content:center;color:#c9cdd4;font-size:14px} +.dubbo-response-status{font-weight:700} +.dubbo-response-status.ok{color:#00b42a} +.dubbo-response-status.err{color:#f53f3f} +.dubbo-response-content{flex:1;overflow-y:auto;padding:12px;font-size:12px;font-family:'Consolas','Courier New',monospace;white-space:pre-wrap;word-break:break-all;line-height:1.6;background:#fff;border-top:1px solid #e5e6eb} +.dubbo-response-content-time{font-size:11px;color:#86909c;padding:4px 12px;background:#fafbfc;border-top:1px solid #f2f3f5} + +/* 底部按钮栏 */ +.dubbo-modal-footer{display:flex;align-items:center;justify-content:flex-end;gap:10px;padding:10px 20px;border-top:1px solid #e5e6eb;flex-shrink:0;background:#fafbfc} +.dubbo-btn{padding:8px 20px;border-radius:4px;font-size:13px;font-weight:600;cursor:pointer;border:1px solid #d0d5dd;background:#fff;color:#4e5969;transition:.15s} +.dubbo-btn:hover{background:#f7f8fa} +.dubbo-btn.primary{background:#722ed1;color:#fff;border-color:#722ed1} +.dubbo-btn.primary:hover{background:#531dab} +.dubbo-btn.send{background:#1677ff;color:#fff;border-color:#1677ff} +.dubbo-btn.send:hover{background:#0958d9} +.dubbo-btn.send:disabled{background:#94bfff;border-color:#94bfff;cursor:not-allowed} +.dubbo-spinner{display:inline-block;width:14px;height:14px;border:2px solid #d0d5dd;border-top-color:#1677ff;border-radius:50%;animation:dubboSpin .6s linear infinite;vertical-align:middle;margin-right:4px} +@keyframes dubboSpin{to{transform:rotate(360deg)}} + +/* 响应式——窄屏时隐藏右栏 */ +@media(max-width:800px){.dubbo-modal-right{display:none}} + +.search-match{box-shadow:0 0 0 2px rgba(22,119,255,.25)} + +/* ===== 响应式 ===== */ +@media(max-width:900px){.sidebar{display:none}.content{max-width:100vw}} diff --git a/src/main/resources/templates/apidoc/api-doc.html b/src/main/resources/templates/apidoc/api-doc.html new file mode 100644 index 0000000..805e177 --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.html @@ -0,0 +1,47 @@ + + + + + +API 接口文档 + + + + + + +
+
📄 API 接口文档
+
+ 接口文档 +
+ +
生成时间: {{GENERATE_TIME}}
+
+ +{{STATS_BAR}} + +
+ + +
+{{CONTENT}} +
+
+ + + + + + diff --git a/src/main/resources/templates/apidoc/api-doc.js b/src/main/resources/templates/apidoc/api-doc.js new file mode 100644 index 0000000..8f79ba6 --- /dev/null +++ b/src/main/resources/templates/apidoc/api-doc.js @@ -0,0 +1,1088 @@ +/* ============================================================ + * API 接口文档交互脚本 + * 从 ApiDocGenerator.appendJs() 提取,版块化便于独立维护 + * + * AI 定位指引: + * - 搜索过滤 → 搜索 "filterDocs" + * - 展开/折叠 → 搜索 "expandAll" / "collapseAll" + * - 复制功能 → 搜索 "copyToClip" / "copyImage" / "copyRtfFromEl" + * - DTO/字段展开 → 搜索 "toggleDto" / "toggleChildren" + * ============================================================ */ + +/* ===== 导航分组折叠 ===== */ +document.querySelectorAll('.nav-group-title').forEach(function(el) { + el.classList.add('open'); +}); +function toggleGroup(el) { + el.classList.toggle('open'); + document.querySelectorAll('.nav-group-title.open + .nav-group-items').forEach(function(i) { + i.style.maxHeight = i.scrollHeight + 'px'; + }); +} +document.querySelectorAll('.nav-group-title.open + .nav-group-items').forEach(function(i) { + i.style.maxHeight = i.scrollHeight + 'px'; +}); + +/* ===== 方法卡片折叠 ===== */ +function toggleMethod(el) { + el.classList.toggle('collapsed'); +} +function toggleDto(el) { + el.classList.toggle('collapsed'); +} + +/* ===== 字段子行折叠 ===== */ +function toggleChildren(el) { + el.classList.toggle('open'); + var row = el.closest('tr'); + if (!row) return; + var d = parseInt(row.dataset.depth) || 0; + var hide = el.classList.contains('open'); + var next = row.nextElementSibling; + while (next && next.classList.contains('field-row')) { + var nd = parseInt(next.dataset.depth) || 0; + if (nd <= d) break; + next.style.display = hide ? '' : 'none'; + next = next.nextElementSibling; + } +} + +/* ===== 展开/折叠全部 ===== */ +function expandAll() { + document.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + document.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); +} +function collapseAll() { + document.querySelectorAll('.method-header').forEach(function(h) { h.classList.add('collapsed'); }); + document.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.add('collapsed'); }); +} + +/* ===== 导航高亮(IntersectionObserver) ===== */ +var observer = new IntersectionObserver(function(entries) { + entries.forEach(function(e) { + if (e.isIntersecting) { + document.querySelectorAll('.nav-item.active').forEach(function(a) { a.classList.remove('active'); }); + var n = document.querySelector('.nav-item[href="#' + e.target.id + '"]'); + if (n) n.classList.add('active'); + } + }); +}); +document.querySelectorAll('.api-card[id]').forEach(function(c) { observer.observe(c); }); + +/* ===== 侧栏导航点击展开对应方法 ===== */ +document.querySelectorAll('.nav-method').forEach(function(link) { + link.addEventListener('click', function(e) { + var targetId = this.getAttribute('href'); + if (!targetId || !targetId.startsWith('#')) return; + var el = document.getElementById(targetId.substring(1)); + if (!el) return; + var methodCard = el.closest('.method-card'); + if (!methodCard) return; + var header = methodCard.querySelector('.method-header'); + if (header) header.classList.remove('collapsed'); + var body = methodCard.querySelector('.method-body'); + if (body) body.style.display = ''; + var apiCard = methodCard.closest('.api-card'); + if (apiCard) { apiCard.classList.remove('collapsed'); var apiBody = apiCard.querySelector('.api-body'); if (apiBody) apiBody.style.display = ''; } + }); +}); + +/* ===== 复制到剪贴板(纯文本) ===== */ +function copyToClip(text, btn) { + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + markCopied(btn, '✔ 已复制'); + }).catch(function() { + fallbackCopy(text, btn); + }); + } else { + fallbackCopy(text, btn); + } +} +function fallbackCopy(text, btn) { + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); markCopied(btn, '✔ 已复制'); } catch (e) { markCopied(btn, '⚠ 失败'); } + document.body.removeChild(ta); +} +function markCopied(btn, msg) { + var orig = btn.textContent; + btn.textContent = msg; + btn.classList.add('copied'); + setTimeout(function() { btn.textContent = orig; btn.classList.remove('copied'); }, 1500); +} + +/* ===== 复制代码签名 ===== */ +function copyCode(btn) { + var pre = btn.closest('.method-signature'); + if (!pre) { var card = btn.closest('.method-card'); if (card) pre = card.querySelector('.method-signature'); } + var codeEl = pre ? pre.querySelector('code') : null; + var text = codeEl ? codeEl.textContent : (pre ? pre.textContent.replace('复制', '') : ''); + copyToClip(text.trim(), btn); +} + +/* ===== 复制为图片(需要 html2canvas) ===== */ +function copyImage(el, btn) { + if (!el || typeof html2canvas === 'undefined') { markCopied(btn, '⚠ 不可用'); return; } + var clone = el.cloneNode(true); + clone.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + clone.querySelectorAll('.method-body').forEach(function(b) { b.style.display = ''; }); + clone.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); + clone.style.position = 'fixed'; + clone.style.left = '-9999px'; + clone.style.top = '0'; + clone.style.zIndex = '-1'; + document.body.appendChild(clone); + html2canvas(clone, { scale: 2, useCORS: true, allowTaint: true, backgroundColor: '#ffffff', windowWidth: clone.scrollWidth, windowHeight: clone.scrollHeight }).then(function(canvas) { + canvas.toBlob(function(blob) { + if (blob && navigator.clipboard && navigator.clipboard.write) { + var item = new ClipboardItem({ 'image/png': blob }); + navigator.clipboard.write([item]).then(function() { + markCopied(btn, '✔ 图片已复制'); + }).catch(function() { + markCopied(btn, '⚠ 请粘贴'); + }); + } else { markCopied(btn, '⚠ 不支持'); } + }, 'image/png'); + }).catch(function() { + markCopied(btn, '⚠ html2canvas 失败'); + }).finally(function() { + if (clone.parentNode) document.body.removeChild(clone); + }); +} + +/* ===== 复制为 Word 富文本 (RTF) ===== */ +function copyRtfFromEl(el, btn) { + if (!el) { markCopied(btn, '⚠ 无内容'); return; } + var wrap = document.createElement('div'); + wrap.innerHTML = el.innerHTML; + // 清除按钮 + wrap.querySelectorAll('button').forEach(function(b) { b.remove(); }); + wrap.querySelectorAll('.copy-btn,.api-copy-btn,.method-copy-btn').forEach(function(b) { b.remove(); }); + wrap.querySelectorAll('.field-expand-toggle').forEach(function(b) { b.remove(); }); + // 展开所有折叠区域 + wrap.querySelectorAll('.method-header').forEach(function(h) { h.classList.remove('collapsed'); }); + wrap.querySelectorAll('.method-body').forEach(function(b) { b.style.display = ''; }); + wrap.querySelectorAll('.dto-block-title').forEach(function(t) { t.classList.remove('collapsed'); }); + // 清除 script/style + wrap.querySelectorAll('script,style').forEach(function(s) { s.remove(); }); + var html = '' + wrap.innerHTML + ''; + if (navigator.clipboard && navigator.clipboard.write) { + var blob = new Blob([html], { type: 'text/html' }); + navigator.clipboard.write([new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([wrap.textContent || ''], { type: 'text/plain' }) })]) + .then(function() { markCopied(btn, '✔ 富文本已复制'); }) + .catch(function() { legacyRich(html, btn); markCopied(btn, '⚠ 已复制富文本'); }); + } else { + legacyRich(html, btn); + markCopied(btn, '⚠ 已复制富文本'); + } +} + +/* ===== 获取文档样式 ===== */ +function getDocStyles() { + var css = ''; + document.querySelectorAll('style').forEach(function(s) { css += s.textContent; }); + return css; +} + +/* ===== 旧版富文本复制兼容 ===== */ +function legacyRich(html, btn) { + var listener = function(e) { + e.clipboardData.setData('text/html', html); + e.clipboardData.setData('text/plain', ''); + e.preventDefault(); + document.removeEventListener('copy', listener); + }; + document.addEventListener('copy', listener); + document.execCommand('copy'); + document.removeEventListener('copy', listener); +} + +/* ===== 搜索过滤 ===== */ +function filterDocs() { + var kw = (document.getElementById('searchInput') || {}).value; + if (!kw || !kw.trim()) { resetSearch(); return; } + kw = kw.trim().toLowerCase(); + var visible = 0; + // 导航过滤 + document.querySelectorAll('.nav-group').forEach(function(g) { + var hasVisible = false; + g.querySelectorAll('.nav-item').forEach(function(item) { + var txt = (item.textContent || '').toLowerCase(); + if (txt.indexOf(kw) >= 0) { item.classList.remove('search-hidden'); hasVisible = true; visible++; } + else { item.classList.add('search-hidden'); } + }); + if (hasVisible) { + g.classList.remove('search-hidden'); + var t = g.querySelector('.nav-group-title'); + if (t) t.classList.add('open'); + var items = g.querySelector('.nav-group-items'); + if (items) items.style.maxHeight = items.scrollHeight + 'px'; + } else { g.classList.add('search-hidden'); } + }); + // 内容过滤 + document.querySelectorAll('.api-card').forEach(function(card) { + var txt = (card.textContent || '').toLowerCase(); + var match = txt.indexOf(kw) >= 0; + if (!match) { + card.querySelectorAll('.method-card').forEach(function(mc) { + var mt = (mc.textContent || '').toLowerCase(); + if (mt.indexOf(kw) >= 0) { match = true; mc.classList.remove('search-hidden'); mc.querySelector('.method-header').classList.remove('collapsed'); mc.querySelector('.method-body').style.display = ''; } + else { mc.classList.add('search-hidden'); } + }); + } + if (match) { card.classList.remove('search-hidden'); var body = card.querySelector('.api-body'); if (body) { body.style.display = ''; card.classList.remove('collapsed'); } } + else { card.classList.add('search-hidden'); } + }); + var searchCount = document.getElementById('searchCount'); + if (searchCount) searchCount.textContent = visible > 0 ? '匹配 ' + visible + ' 项' : '无匹配'; +} + +function resetSearch() { + document.querySelectorAll('.search-hidden').forEach(function(el) { el.classList.remove('search-hidden'); }); + document.querySelectorAll('.search-highlight').forEach(function(el) { var p = el.parentNode; if (p) p.replaceChild(document.createTextNode(el.textContent), el); }); + var searchCount = document.getElementById('searchCount'); + if (searchCount) searchCount.textContent = ''; +} + +/* ===== ESC 清空搜索 ===== */ +(function() { + var si = document.getElementById('searchInput'); + if (si) { si.addEventListener('keydown', function(e) { if (e.key === 'Escape') { this.value = ''; resetSearch(); this.blur(); } }); } +})(); + +/* ===== 手动选择区域复制富文本兼容 ===== */ +document.querySelectorAll('.api-card .api-body, .method-body').forEach(function(el) { + el.addEventListener('copy', function(e) { + var sel = window.getSelection(); + if (!sel.rangeCount) return; + var range = sel.getRangeAt(0); + var wrapper = document.createElement('div'); + wrapper.appendChild(range.cloneContents()); + wrapper.querySelectorAll('button,.copy-btn,.api-copy-btn,.method-copy-btn').forEach(function(b) { b.remove(); }); + var html = wrapper.innerHTML; + var css = getDocStyles(); + e.clipboardData.setData('text/html', '' + html + ''); + e.preventDefault(); + }); +}); + +/* ============================================================ + * Dubbo 接口调试 + * ============================================================ */ + +/** 默认网关 URL */ +var DUBBO_GW_URL_KEY = 'dubbo_gw_url'; +function getGwUrl() { + return localStorage.getItem(DUBBO_GW_URL_KEY) || 'https://gw.test.bestpay.net/open/1.0/GenericFacade/genericInvoke'; +} +function setGwUrl(url) { localStorage.setItem(DUBBO_GW_URL_KEY, url); } + +/** 默认 header */ +function defaultHeaders() { + return { + 'Content-Type': 'application/json', + 'User-Agent': 'ApiDoc-Debugger/1.0', + 'Accept': '*/*' + }; +} + +/** 构建 eventContext 的值 — 注意内外 env 是不同概念 */ +function buildEventContext(tntId, env, evtEnv) { + return JSON.stringify({ + tntId:tntId||'0101', env:evtEnv||env||'DEV', arNo:'', evtCd:'', pdCd:'', pdPath:'' + }); +} + +/** 根据 env 下拉选中值计算默认 eventContext.env */ +function getDefaultEvtEnv(envVal) { + if (envVal === 'DEV') return 'DEV_1156339215001361525'; + if (envVal === 'SIT') { + var now = new Date(); + var y = now.getFullYear(); + var m = pad2(now.getMonth() + 1); + var d = pad2(now.getDate()); + return 'SIT-' + y + m + d + '01'; + } + // STB 或其他 → 空 + return ''; +} + +/** env 下拉变更时自动填充 eventContext.env */ +function onEnvChange() { + var envEl = document.getElementById('dubboEnv'); + var evtEl = document.getElementById('dubboEvtEnv'); + if (!envEl || !evtEl) return; + evtEl.value = getDefaultEvtEnv(envEl.value); +} + +/** 打开 Dubbo 调试弹窗 */ +function openDubboDebug(card) { + window._dubboActiveCard = card; + var iface = card.getAttribute('data-dubbo-interface'); + var method = card.getAttribute('data-dubbo-method'); + var paramTypes = card.getAttribute('data-dubbo-param-types') || '[]'; + var sampleArgs = card.getAttribute('data-dubbo-sample-args') || '[{}]'; + var stateKey = getDubboStateKey(iface, method); + var saved = loadDubboState(stateKey); + + var gwUrl = (saved && saved.gwUrl) ? saved.gwUrl : getGwUrl(); + var argsVal = (saved && saved.args) ? saved.args : formatJsonPretty(sampleArgs); + var tntVal = (saved && saved.tntId) ? saved.tntId : '0101'; + var envVal = (saved && saved.env) ? saved.env : 'DEV'; + var appVal = (saved && saved.appId) ? saved.appId : '980010000114002'; + var evtEnvVal = (saved && saved.evtEnv) ? saved.evtEnv : getDefaultEvtEnv(envVal); + + var envOpts = ['DEV','SIT','STB'].map(function(v) { + return '' + v + ''; + }).join(''); + + var lastRespHtml = ''; + if (saved && saved.lastResponse) { + lastRespHtml = '
' + + '
响应结果' + + '' + + escHtml(saved.lastResponse.status) + '
' + + (saved.lastResponse.time ? '
📅 ' + escHtml(saved.lastResponse.time) + '
' : '') + + '
' + escHtml(saved.lastResponse.body) + '
'; + } else { + lastRespHtml = '
' + + '
响应结果
' + + '
点击发送请求查看结果
'; + } + + var html = '
' + + '
' + + '
' + + '🔧 ' + escHtml(method) + ' — 接口调试' + + '' + + '
' + + '
' + + // 左栏:表单 + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + // 右栏:响应 + '
' + + lastRespHtml + + '
' + + '
' + + '' + + '
'; + + // 移除已有弹窗 + var old = document.getElementById('dubboMask'); + if (old) old.remove(); + document.body.insertAdjacentHTML('beforeend', html); + + // 如果有保存的响应结果,展开显示 + if (saved && saved.lastResponse) { + var respContent = document.getElementById('dubboRespContent'); + if (respContent) { respContent.style.display = ''; } + var emptyEl = document.querySelector('.dubbo-response-panel-empty'); + if (emptyEl) { emptyEl.style.display = 'none'; } + } +} + +/** 关闭弹窗 */ +function closeDubboDebug() { + var mask = document.getElementById('dubboMask'); + if (mask) mask.remove(); +} + +/** localStorage key */ +function getDubboStateKey(iface, method) { + return 'dubbo_debug_' + (iface||'') + '_' + (method||''); +} + +/** 保存当前调试状态到 localStorage */ +function saveDubboState() { + var card = getActiveDubboCard(); + if (!card) return; + var iface = card.getAttribute('data-dubbo-interface'); + var method = card.getAttribute('data-dubbo-method'); + var key = getDubboStateKey(iface, method); + var state = { + gwUrl: (document.getElementById('dubboGwUrl') || {}).value || '', + args: (document.getElementById('dubboArgs') || {}).value || '', + tntId: (document.getElementById('dubboTntId') || {}).value || '', + env: (document.getElementById('dubboEnv') || {}).value || '', + appId: (document.getElementById('dubboAppId') || {}).value || '', + evtEnv: (document.getElementById('dubboEvtEnv') || {}).value || '' + }; + // 保留已有的响应结果(如果有) + var existing = loadDubboState(key); + if (existing && existing.lastResponse) { + state.lastResponse = existing.lastResponse; + } + try { localStorage.setItem(key, JSON.stringify(state)); } catch(e) {} +} + +/** 读取调试状态 */ +function loadDubboState(key) { + try { + var raw = localStorage.getItem(key); + return raw ? JSON.parse(raw) : null; + } catch(e) { return null; } +} + +/** 保存响应结果 */ +function saveDubboResponse(statusText, body, ok) { + var card = getActiveDubboCard(); + if (!card) return; + var iface = card.getAttribute('data-dubbo-interface'); + var method = card.getAttribute('data-dubbo-method'); + var key = getDubboStateKey(iface, method); + var state = loadDubboState(key) || {}; + state.lastResponse = { status: statusText, body: body, ok: ok, time: formatLocalDateTime() }; + // 同时保存当前表单字段 + state.gwUrl = (document.getElementById('dubboGwUrl') || {}).value || state.gwUrl || ''; + state.args = (document.getElementById('dubboArgs') || {}).value || state.args || ''; + state.tntId = (document.getElementById('dubboTntId') || {}).value || state.tntId || ''; + state.env = (document.getElementById('dubboEnv') || {}).value || state.env || ''; + state.appId = (document.getElementById('dubboAppId') || {}).value || state.appId || ''; + state.evtEnv = (document.getElementById('dubboEvtEnv') || {}).value || state.evtEnv || ''; + try { localStorage.setItem(key, JSON.stringify(state)); } catch(e) {} +} + +/** 重置 args 为默认示例 */ +function resetDubboArgs() { + var card = getActiveDubboCard(); + if (!card) return; + var sampleArgs = card.getAttribute('data-dubbo-sample-args') || '[{}]'; + var ta = document.getElementById('dubboArgs'); + if (ta) ta.value = formatJsonPretty(sampleArgs); +} + +// 去除 JSON 中的单行和多行注释(兼容 Apifox 等工具复制出来的格式) +function stripJsonComments(jsonStr) { + if (!jsonStr) return ''; + return jsonStr + .replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, function(m, g) { return g ? '' : m; }) + .replace(/,\s*\}/g, '}').replace(/,\s*\]/g, ']'); // 去掉尾部逗号 +} + +/** 格式化 JSON */ +function formatJsonPretty(jsonStr) { + try { + var obj = JSON.parse(stripJsonComments(jsonStr)); + return JSON.stringify(obj, null, 2); + } catch(e) { return jsonStr; } +} + +/** 对 textarea 中的 JSON 进行格式化 */ +function formatDubboArgs() { + var ta = document.getElementById('dubboArgs'); + if (!ta) return; + ta.value = formatJsonPretty(ta.value); +} + +/* ============================================================ + * 日志提取功能 — 从日志中解析 DTO 参数填入调试面板 + * ============================================================ */ + +/** 显示日志提取弹窗 */ +function showLogExtractPopup() { + var old = document.getElementById('logExtractOverlay'); + if (old) old.remove(); + var overlay = document.createElement('div'); + overlay.id = 'logExtractOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:100000;display:flex;align-items:center;justify-content:center'; + overlay.innerHTML = + '
' + + '
' + + '📋 从日志提取入参' + + '' + + '
' + + '
' + + '
粘贴日志内容,自动识别 接口入参:ClassName(field=value,...) 格式,
也支持只粘贴 DTO 参数部分。
' + + '' + + '
' + + '' + + '' + + '
' + + '
'; + document.body.appendChild(overlay); + overlay.addEventListener('click', function(e) { if (e.target === overlay) closeLogExtractPopup(); }); + setTimeout(function() { var ta = document.getElementById('logExtractInput'); if (ta) ta.focus(); }, 100); +} + +function closeLogExtractPopup() { + var el = document.getElementById('logExtractOverlay'); + if (el) el.remove(); +} + +/** 执行日志解析 → 弹出比对窗口 */ +function doExtractLogArgs() { + var raw = (document.getElementById('logExtractInput') || {}).value; + if (!raw || !raw.trim()) { showToast('⚠ 请粘贴日志内容'); return; } + raw = raw.trim(); + var dtoText = raw; + var markers = ['接口入参:', '接口入参:', '入参:', '入参:']; + for (var i = 0; i < markers.length; i++) { + var idx = raw.lastIndexOf(markers[i]); + if (idx >= 0) { dtoText = raw.substring(idx + markers[i].length).trim(); break; } + } + try { + var logObj = flattenSuperChain(parseDtoExpr(dtoText)); + var card = getActiveDubboCard(); + var sampleObj = {}; + if (card) { + try { + var sampleArr = JSON.parse(card.getAttribute('data-dubbo-sample-args') || '[{}]'); + if (Array.isArray(sampleArr) && sampleArr.length > 0) sampleObj = sampleArr[0]; + } catch (ignored) {} + } + closeLogExtractPopup(); + showParamComparePopup(sampleObj, logObj); + } catch(e) { + showToast('❌ 解析失败:' + e.message); + } +} + +/** 参数比对弹窗:默认参考值 vs 日志提取值 */ +function showParamComparePopup(sampleObj, logObj) { + var old = document.getElementById('paramCompareOverlay'); + if (old) old.remove(); + + var diffs = diffObjects(sampleObj, logObj, ''); + var changedCount = 0, sampleOnlyCount = 0; + diffs.forEach(function(d) { + if (d.status === 'changed') changedCount++; + else if (d.status === 'sample_only') sampleOnlyCount++; + }); + + var rowsHtml = ''; + diffs.forEach(function(d, idx) { + var badge, rowStyle; + if (d.status === 'changed') { + badge = '变更'; + rowStyle = 'background:#fffbe6'; + } else if (d.status === 'sample_only') { + badge = '仅默认'; + rowStyle = 'background:#f0f8ff'; + } else if (d.status === 'log_only') { + badge = '仅日志'; + rowStyle = 'background:#f6ffed'; + } else { + badge = '一致'; + rowStyle = ''; + } + var sampleDisplay = d.sampleVal === undefined ? '—' : escHtml(fmtDiffVal(d.sampleVal)); + var logDisplay = d.logVal === undefined ? '—' : escHtml(fmtDiffVal(d.logVal)); + rowsHtml += '' + + '' + badge + '' + + '' + escHtml(d.path || '(根)') + '' + + '' + sampleDisplay + '' + + '' + logDisplay + '' + + ''; + }); + + var overlay = document.createElement('div'); + overlay.id = 'paramCompareOverlay'; + overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:100001;display:flex;align-items:center;justify-content:center'; + overlay.innerHTML = + '
' + + '
' + + '
🔄 参数比对 — 默认值 vs 日志值' + + '' + + (changedCount > 0 ? ' ' + changedCount + ' 项变更' : '') + + (sampleOnlyCount > 0 ? ' ' + sampleOnlyCount + ' 项仅默认' : '') + + '
' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rowsHtml + '' + + '
状态参数路径默认参考值日志提取值
' + + (diffs.length === 0 ? '
无差异,参数完全一致
' : '') + + '
' + + '
' + + '' + + '' + + '' + + '
'; + document.body.appendChild(overlay); + overlay.addEventListener('click', function(e) { if (e.target === overlay) closeParamComparePopup(); }); + + // 暂存原始对象 + overlay._sampleObj = sampleObj; + overlay._logObj = logObj; + overlay._diffCount = diffs.length; +} + +function closeParamComparePopup() { + var el = document.getElementById('paramCompareOverlay'); + if (el) el.remove(); +} + +/** 比对结果应用 */ +var _compareResultLogObj = null, _compareResultSampleObj = null; +function applyCompareResult(mode) { + var overlay = document.getElementById('paramCompareOverlay'); + var sampleObj = overlay._sampleObj || {}; + var logObj = overlay._logObj || {}; + var finalObj; + if (mode === 'merged') { + finalObj = deepMerge(sampleObj, logObj); + } else { + finalObj = logObj; + } + var json = JSON.stringify([finalObj], null, 2); + var ta = document.getElementById('dubboArgs'); + if (ta) { ta.value = json; showToast('✅ 参数已填入(共 ' + (overlay._diffCount || 0) + ' 个字段参与比对)'); } + closeParamComparePopup(); +} + +/** 递归对比两个对象,返回差异列表 [{path, sampleVal, logVal, status}] */ +function diffObjects(sample, log, path) { + var results = []; + var sampleIsObj = sample && typeof sample === 'object' && !Array.isArray(sample); + var logIsObj = log && typeof log === 'object' && !Array.isArray(log); + + if (!sampleIsObj && !logIsObj) { + // 都不是对象,直接比较值 + var sv = sample; + var lv = log; + if (sv === undefined && lv !== undefined) results.push({path: path, sampleVal: undefined, logVal: lv, status: 'log_only'}); + else if (lv === undefined && sv !== undefined) results.push({path: path, sampleVal: sv, logVal: undefined, status: 'sample_only'}); + else if (!eqVal(sv, lv)) results.push({path: path, sampleVal: sv, logVal: lv, status: 'changed'}); + return results; + } + + // 处理:一个对象一个非对象 + if (sampleIsObj !== logIsObj) { + if (!eqVal(sample, log)) results.push({path: path, sampleVal: sample, logVal: log, status: 'changed'}); + return results; + } + + // 都是对象,递归比较 key + var seen = {}; + for (var key in sample) { + if (!sample.hasOwnProperty(key)) continue; + seen[key] = true; + var childPath = path ? path + '.' + key : key; + results = results.concat(diffObjects(sample[key], log ? log[key] : undefined, childPath)); + } + for (var key2 in log) { + if (!log.hasOwnProperty(key2) || seen[key2]) continue; + var childPath2 = path ? path + '.' + key2 : key2; + results.push({path: childPath2, sampleVal: undefined, logVal: log[key2], status: 'log_only'}); + } + return results; +} + +function eqVal(a, b) { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + if (typeof a === 'object' || typeof b === 'object') return JSON.stringify(a) === JSON.stringify(b); + return String(a) === String(b); +} + +function fmtDiffVal(v) { + if (v === undefined || v === null) return 'null'; + if (typeof v === 'object') return JSON.stringify(v); + return String(v); +} + +/** 解析 DTO 表达式 ClassName(field=value, ...),返回 JS 对象 */ +function parseDtoExpr(text) { + text = text.trim(); + // 找出最外层 ClassName(...) 的括号范围 + var m = text.match(/^([\w.]+)\s*\(([\s\S]*)\)$/); + if (!m) { + var found = findOuterParens(text); + if (!found) throw new Error('未识别到 DTO 表达式,期望格式: ClassName(field=value,...)'); + text = found; + m = text.match(/^([\w.]+)\s*\(([\s\S]*)\)$/); + if (!m) throw new Error('DTO 格式异常'); + } + var obj = {}; + var content = m[2]; + var pairs = splitTopLevel(content, ','); + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].trim(); + if (!pair) continue; + // 在顶层找第一个 '=' + var eqIdx = findTopLevelChar(pair, '='); + if (eqIdx < 0) continue; + var key = pair.substring(0, eqIdx).trim(); + var valStr = pair.substring(eqIdx + 1).trim(); + obj[key] = parseDtoValue(valStr); + } + return obj; +} + +/** 在文本中定位最外层用括号包裹的 ClassName(...) 片段 */ +function findOuterParens(text) { + var m = text.match(/([\w.]+)\s*\(/); + if (!m) return null; + var start = m.index; + var openIdx = start + m[0].length - 1; + var depth = 1; + for (var i = openIdx + 1; i < text.length; i++) { + if (text[i] === '(') depth++; + else if (text[i] === ')') depth--; + if (depth === 0) return text.substring(start, i + 1); + } + return null; +} + +/** 按分隔符拆分字符串,仅拆分括号深度为 0 的位置 */ +function splitTopLevel(str, sep) { + var parts = []; + var start = 0; + var depth = 0; + for (var i = 0; i < str.length; i++) { + var ch = str[i]; + if (ch === '(' || ch === '[') depth++; + else if (ch === ')' || ch === ']') depth--; + else if (ch === sep && depth === 0) { + parts.push(str.substring(start, i)); + start = i + 1; + } + } + parts.push(str.substring(start)); + return parts; +} + +/** 在字符串中找括号深度为 0 的位置 */ +function findTopLevelChar(str, ch) { + var depth = 0; + for (var i = 0; i < str.length; i++) { + if (str[i] === '(' || str[i] === '[') depth++; + else if (str[i] === ')' || str[i] === ']') depth--; + else if (str[i] === ch && depth === 0) return i; + } + return -1; +} + +/** 递归解析 DTO 值:嵌套 DTO / 列表 / 基本值 */ +function parseDtoValue(val) { + val = val.trim(); + if (!val) return null; + // 列表:[item, item, ...] + if (val.charAt(0) === '[' && val.charAt(val.length - 1) === ']') { + var inner = val.substring(1, val.length - 1).trim(); + if (!inner) return []; + var items = splitTopLevel(inner, ','); + var arr = []; + for (var i = 0; i < items.length; i++) { + var item = items[i].trim(); + if (!item) continue; + arr.push(parseDtoValue(item)); + } + return arr; + } + // 嵌套 DTO:ClassName(...) + if (/^[\w.]+\s*\(/.test(val)) return parseDtoExpr(val); + // 匿名对象:super=(requestSystem="CF_MIX", ...) + if (val.charAt(0) === '(' && val.charAt(val.length - 1) === ')') { + return parseAnonymousDto(val.substring(1, val.length - 1)); + } + // null + if (val === 'null' || val === 'NULL') return null; + // 日志中的 DTO 表达式不区分类型(string/number/boolean),统一按字符串返回, + // Dubbo 序列化时由方法签名决定最终类型,避免误判(如 loanNo=211254457192 被当成数字截断) + // Date 格式转换:Tue Jul 28 18:10:00 CST 2026 → 2026-07-28 18:10:00 + if (/^\w{3}\s\w{3}\s\d{1,2}\s\d{2}:\d{2}:\d{2}\s\w+\s\d{4}$/.test(val)) { + var d = new Date(val); + if (!isNaN(d.getTime())) { + return d.getFullYear() + '-' + + pad2(d.getMonth() + 1) + '-' + + pad2(d.getDate()) + ' ' + + pad2(d.getHours()) + ':' + + pad2(d.getMinutes()) + ':' + + pad2(d.getSeconds()); + } + } + return val; +} + +/** 两位数补零 */ +function pad2(n) { return n < 10 ? '0' + n : '' + n; } + +/** 解析匿名对象 (field=value, ...),没有类名前缀 */ +function parseAnonymousDto(content) { + var obj = {}; + var pairs = splitTopLevel(content, ','); + for (var i = 0; i < pairs.length; i++) { + var pair = pairs[i].trim(); + if (!pair) continue; + var eqIdx = findTopLevelChar(pair, '='); + if (eqIdx < 0) continue; + var key = pair.substring(0, eqIdx).trim(); + var valStr = pair.substring(eqIdx + 1).trim(); + obj[key] = parseDtoValue(valStr); + } + return obj; +} + +/** 扁平化 super 继承链。 + * 日志中 super=TradeRequestDTO(super=BaseRequestDTO(...), ...) 嵌套结构 + * 需要把 super 中的父类字段提升到当前层级,使比对路径与默认值对齐 */ +function flattenSuperChain(obj) { + if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return obj; + var result = {}; + for (var key in obj) { + if (!obj.hasOwnProperty(key)) continue; + if (key === 'super' && obj[key] && typeof obj[key] === 'object' && !Array.isArray(obj[key])) { + var superFlat = flattenSuperChain(obj[key]); + for (var sKey in superFlat) { + if (superFlat.hasOwnProperty(sKey)) result[sKey] = superFlat[sKey]; + } + } else if (key !== 'super') { + result[key] = obj[key]; + } + } + return result; +} + +/** 深度合并:src 覆盖 base,base 补齐 src 缺失字段。仅处理普通对象,数组直接覆盖 */ +function deepMerge(base, src) { + if (!base || typeof base !== 'object') return src; + if (!src || typeof src !== 'object') return base; + if (Array.isArray(base) || Array.isArray(src)) return src; + var result = {}; + for (var key in base) { + if (base.hasOwnProperty(key)) { + if (src.hasOwnProperty(key)) { + result[key] = deepMerge(base[key], src[key]); + } else { + result[key] = base[key]; + } + } + } + for (var key2 in src) { + if (src.hasOwnProperty(key2) && !base.hasOwnProperty(key2)) { + result[key2] = src[key2]; + } + } + return result; +} + +/** HTML 转义 */ +function escHtml(s) { + if (!s) return ''; + return s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); +} + +/** 获取本地时区的 yyyy-MM-dd HH:mm:ss */ +function formatLocalDateTime() { + var d = new Date(); + var pad = function(n) { return (n < 10 ? '0' : '') + n; }; + return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()); +} + +/** 构建请求 body */ +function buildDubboBody() { + var argsText = document.getElementById('dubboArgs') ? document.getElementById('dubboArgs').value : '{}'; + var tntId = document.getElementById('dubboTntId') ? document.getElementById('dubboTntId').value : '0101'; + var env = document.getElementById('dubboEnv') ? document.getElementById('dubboEnv').value : 'STB'; + var evtEnv = document.getElementById('dubboEvtEnv') ? document.getElementById('dubboEvtEnv').value : 'DEV'; + var appId = document.getElementById('dubboAppId') ? document.getElementById('dubboAppId').value : '980010000114002'; + var card = getActiveDubboCard(); + var iface = card ? card.getAttribute('data-dubbo-interface') : ''; + var method = card ? card.getAttribute('data-dubbo-method') : ''; + var paramTypes = card ? card.getAttribute('data-dubbo-param-types') || '[]' : '[]'; + + var args; + try { args = JSON.parse(stripJsonComments(argsText)); } catch(e) { alert('args JSON 格式错误: ' + e.message); return null; } + + return { + data: { + interfacePath: iface, + method: method, + parameterTypes: JSON.parse(paramTypes), + args: args, + traceId: genTraceId(), + zoneType: 'G', + eventContext: { + tntId: tntId, ipTId:'', ipId:'null', ipRId:'', chlNo: genTraceId(), + originalArNo:'', originalEvtCd:'', arNo:'', chlEvtCd:'', + prePdCd:'null', preEvtCd:'null', pdCd:'', evtCd:'', pdPath:'', + env: evtEnv, tagExtensionInfo:'{}', shadow:'0' + } + }, + tnt_id: tntId, + app_id: appId, + timestamp: formatLocalDateTime(), + env: env + }; +} + +/** 获取当前打开调试的那个 method-card */ +function getActiveDubboCard() { + var mask = document.getElementById('dubboMask'); + if (!mask) return null; + // 从触发按钮找:记录最后点击的 card + return window._dubboActiveCard || document.querySelector('.method-card[data-dubbo-interface]'); +} + +/** 生成 traceId */ +function genTraceId() { + return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + var r = Math.random()*16|0, v = c==='x'? r : (r&0x3|0x8); + return v.toString(16); + }); +} + +/** 发送 Dubbo 请求 — 通过本地 Java 代理转发,解决浏览器无法直连内网网关的问题 */ +function sendDubboRequest() { + var body = buildDubboBody(); + if (!body) return; + + // 发送前保存当前表单状态 + saveDubboState(); + + var gwUrl = document.getElementById('dubboGwUrl') ? document.getElementById('dubboGwUrl').value : getGwUrl(); + setGwUrl(gwUrl); + + var btn = document.getElementById('dubboSendBtn'); + btn.disabled = true; + btn.innerHTML = '请求中...'; + + // 确保右栏响应面板存在 + var statusEl = document.getElementById('dubboStatus'); + var contentEl = document.getElementById('dubboRespContent'); + var emptyEl = document.querySelector('.dubbo-response-panel-empty'); + if (emptyEl) emptyEl.style.display = 'none'; + if (!contentEl) { + var rightCol = document.querySelector('.dubbo-modal-right'); + if (rightCol) { + rightCol.innerHTML = '
' + + '
响应结果
' + + '
'; + contentEl = document.getElementById('dubboRespContent'); + statusEl = document.getElementById('dubboStatus'); + } + } + statusEl.textContent = '请求中...'; + statusEl.className = 'dubbo-response-status'; + contentEl.textContent = ''; + + // 去除旧的 time 行 + var oldTime = document.querySelector('.dubbo-response-content-time'); + if (oldTime) oldTime.remove(); + + var headers = defaultHeaders(); + var evtEnv = document.getElementById('dubboEvtEnv') ? document.getElementById('dubboEvtEnv').value : 'DEV'; + headers['Event-Context'] = buildEventContext(body.tnt_id, body.env, evtEnv); + headers['X-Gateway-Url'] = gwUrl; + + // 走本地 Java 代理,避免浏览器 CORS/网络限制 + fetch(DUBBO_PROXY_URL, { + method: 'POST', + headers: headers, + body: JSON.stringify(body) + }).then(function(res) { + var statusOk = res.ok; + var statusText = res.status + ' ' + res.statusText; + statusEl.textContent = statusText; + statusEl.className = 'dubbo-response-status ' + (statusOk ? 'ok' : 'err'); + return res.text().then(function(txt) { + var displayText; + try { + var j = JSON.parse(txt); + displayText = JSON.stringify(j, null, 2); + } catch(e) { + displayText = txt; + } + contentEl.textContent = displayText; + // 保存响应结果到 localStorage + saveDubboResponse(statusText, displayText, statusOk); + // 追加时间戳显示 + var timeEl = document.querySelector('.dubbo-response-content-time'); + if (timeEl) timeEl.remove(); + var timeHtml = '
📅 ' + formatLocalDateTime() + '
'; + contentEl.insertAdjacentHTML('beforebegin', timeHtml); + btn.disabled = false; + btn.textContent = '▶ 发送请求'; + }); + }).catch(function(err) { + var errMsg = err.message || String(err); + statusEl.textContent = '网络错误'; + statusEl.className = 'dubbo-response-status err'; + contentEl.textContent = errMsg; + saveDubboResponse('网络错误', errMsg, false); + btn.disabled = false; + btn.textContent = '▶ 发送请求'; + }); +} + +/** 复制为 cURL */ +function copyDubboCurl() { + var body = buildDubboBody(); + if (!body) return; + var gwUrl = document.getElementById('dubboGwUrl') ? document.getElementById('dubboGwUrl').value : getGwUrl(); + var headers = defaultHeaders(); + var evtEnv = document.getElementById('dubboEvtEnv') ? document.getElementById('dubboEvtEnv').value : 'DEV'; + headers['Event-Context'] = buildEventContext(body.tnt_id, body.env, evtEnv); + var curl = 'curl -X POST \\\n ' + JSON.stringify(gwUrl) + ' \\\n'; + for (var k in headers) { + curl += ' -H ' + JSON.stringify(k + ': ' + headers[k]) + ' \\\n'; + } + curl += ' -d ' + JSON.stringify(JSON.stringify(body, null, 2)); + copyToClip(curl, null); + showToast('cURL 已复制'); +} + +/** 复制请求体 JSON */ +function copyDubboBody() { + var body = buildDubboBody(); + if (!body) return; + copyToClip(JSON.stringify(body, null, 2), null); + showToast('请求体已复制'); +} + +/** Toast 提示 */ +function showToast(msg) { + var t = document.getElementById('dubboToast'); + if (!t) { t = document.createElement('div'); t.id = 'dubboToast'; t.style.cssText = 'position:fixed;bottom:32px;left:50%;transform:translateX(-50%);background:#1d2129;color:#fff;padding:8px 24px;border-radius:6px;font-size:13px;z-index:99999;pointer-events:none;transition:opacity .3s'; document.body.appendChild(t); } + t.textContent = msg; + t.style.opacity = '1'; + clearTimeout(t._tid); + t._tid = setTimeout(function(){ t.style.opacity = '0'; }, 1800); +} + +// 修复:track 最后点击的 dubbo card +document.addEventListener('click', function(e) { + var card = e.target.closest('.method-card[data-dubbo-interface]'); + if (card) window._dubboActiveCard = card; +}); diff --git a/src/main/resources/templates/apidoc/html2canvas.min.js b/src/main/resources/templates/apidoc/html2canvas.min.js new file mode 100644 index 0000000..aed6bfd --- /dev/null +++ b/src/main/resources/templates/apidoc/html2canvas.min.js @@ -0,0 +1,20 @@ +/*! + * html2canvas 1.4.1 + * Copyright (c) 2022 Niklas von Hertzen + * Released under MIT License + */ +!function(A,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(A="undefined"!=typeof globalThis?globalThis:A||self).html2canvas=e()}(this,function(){"use strict"; +/*! ***************************************************************************** + Copyright (c) Microsoft Corporation. + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM + LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR + OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THIS SOFTWARE. + ***************************************************************************** */var r=function(A,e){return(r=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(A,e){A.__proto__=e}||function(A,e){for(var t in e)Object.prototype.hasOwnProperty.call(e,t)&&(A[t]=e[t])})(A,e)};function A(A,e){if("function"!=typeof e&&null!==e)throw new TypeError("Class extends value "+String(e)+" is not a constructor or null");function t(){this.constructor=A}r(A,e),A.prototype=null===e?Object.create(e):(t.prototype=e.prototype,new t)}var h=function(){return(h=Object.assign||function(A){for(var e,t=1,r=arguments.length;ts[0]&&e[1]>10),s%1024+56320)),(B+1===t||16384>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},l);function l(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var C="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",u="undefined"==typeof Uint8Array?[]:new Uint8Array(256),F=0;F>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(y="KwAAAAAAAAAACA4AUD0AADAgAAACAAAAAAAIABAAGABAAEgAUABYAGAAaABgAGgAYgBqAF8AZwBgAGgAcQB5AHUAfQCFAI0AlQCdAKIAqgCyALoAYABoAGAAaABgAGgAwgDKAGAAaADGAM4A0wDbAOEA6QDxAPkAAQEJAQ8BFwF1AH0AHAEkASwBNAE6AUIBQQFJAVEBWQFhAWgBcAF4ATAAgAGGAY4BlQGXAZ8BpwGvAbUBvQHFAc0B0wHbAeMB6wHxAfkBAQIJAvEBEQIZAiECKQIxAjgCQAJGAk4CVgJeAmQCbAJ0AnwCgQKJApECmQKgAqgCsAK4ArwCxAIwAMwC0wLbAjAA4wLrAvMC+AIAAwcDDwMwABcDHQMlAy0DNQN1AD0DQQNJA0kDSQNRA1EDVwNZA1kDdQB1AGEDdQBpA20DdQN1AHsDdQCBA4kDkQN1AHUAmQOhA3UAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AKYDrgN1AHUAtgO+A8YDzgPWAxcD3gPjA+sD8wN1AHUA+wMDBAkEdQANBBUEHQQlBCoEFwMyBDgEYABABBcDSARQBFgEYARoBDAAcAQzAXgEgASIBJAEdQCXBHUAnwSnBK4EtgS6BMIEyAR1AHUAdQB1AHUAdQCVANAEYABgAGAAYABgAGAAYABgANgEYADcBOQEYADsBPQE/AQEBQwFFAUcBSQFLAU0BWQEPAVEBUsFUwVbBWAAYgVgAGoFcgV6BYIFigWRBWAAmQWfBaYFYABgAGAAYABgAKoFYACxBbAFuQW6BcEFwQXHBcEFwQXPBdMF2wXjBeoF8gX6BQIGCgYSBhoGIgYqBjIGOgZgAD4GRgZMBmAAUwZaBmAAYABgAGAAYABgAGAAYABgAGAAYABgAGIGYABpBnAGYABgAGAAYABgAGAAYABgAGAAYAB4Bn8GhQZgAGAAYAB1AHcDFQSLBmAAYABgAJMGdQA9A3UAmwajBqsGqwaVALMGuwbDBjAAywbSBtIG1QbSBtIG0gbSBtIG0gbdBuMG6wbzBvsGAwcLBxMHAwcbByMHJwcsBywHMQcsB9IGOAdAB0gHTgfSBkgHVgfSBtIG0gbSBtIG0gbSBtIG0gbSBiwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdgAGAALAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAdbB2MHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB2kH0gZwB64EdQB1AHUAdQB1AHUAdQB1AHUHfQdgAIUHjQd1AHUAlQedB2AAYAClB6sHYACzB7YHvgfGB3UAzgfWBzMB3gfmB1EB7gf1B/0HlQENAQUIDQh1ABUIHQglCBcDLQg1CD0IRQhNCEEDUwh1AHUAdQBbCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIaQhjCGQIZQhmCGcIaAhpCGMIZAhlCGYIZwhoCGkIYwhkCGUIZghnCGgIcAh3CHoIMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIgggwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAALAcsBywHLAcsBywHLAcsBywHLAcsB4oILAcsB44I0gaWCJ4Ipgh1AHUAqgiyCHUAdQB1AHUAdQB1AHUAdQB1AHUAtwh8AXUAvwh1AMUIyQjRCNkI4AjoCHUAdQB1AO4I9gj+CAYJDgkTCS0HGwkjCYIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiCCIIIggiAAIAAAAFAAYABgAGIAXwBgAHEAdQBFAJUAogCyAKAAYABgAEIA4ABGANMA4QDxAMEBDwE1AFwBLAE6AQEBUQF4QkhCmEKoQrhCgAHIQsAB0MLAAcABwAHAAeDC6ABoAHDCwMMAAcABwAHAAdDDGMMAAcAB6MM4wwjDWMNow3jDaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAGgAaABoAEjDqABWw6bDqABpg6gAaABoAHcDvwOPA+gAaABfA/8DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DvwO/A78DpcPAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcAB9cPKwkyCToJMAB1AHUAdQBCCUoJTQl1AFUJXAljCWcJawkwADAAMAAwAHMJdQB2CX4JdQCECYoJjgmWCXUAngkwAGAAYABxAHUApgn3A64JtAl1ALkJdQDACTAAMAAwADAAdQB1AHUAdQB1AHUAdQB1AHUAowYNBMUIMAAwADAAMADICcsJ0wnZCRUE4QkwAOkJ8An4CTAAMAB1AAAKvwh1AAgKDwoXCh8KdQAwACcKLgp1ADYKqAmICT4KRgowADAAdQB1AE4KMAB1AFYKdQBeCnUAZQowADAAMAAwADAAMAAwADAAMAAVBHUAbQowADAAdQC5CXUKMAAwAHwBxAijBogEMgF9CoQKiASMCpQKmgqIBKIKqgquCogEDQG2Cr4KxgrLCjAAMADTCtsKCgHjCusK8Qr5CgELMAAwADAAMAB1AIsECQsRC3UANAEZCzAAMAAwADAAMAB1ACELKQswAHUANAExCzkLdQBBC0kLMABRC1kLMAAwADAAMAAwADAAdQBhCzAAMAAwAGAAYABpC3ELdwt/CzAAMACHC4sLkwubC58Lpwt1AK4Ltgt1APsDMAAwADAAMAAwADAAMAAwAL4LwwvLC9IL1wvdCzAAMADlC+kL8Qv5C/8LSQswADAAMAAwADAAMAAwADAAMAAHDDAAMAAwADAAMAAODBYMHgx1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1ACYMMAAwADAAdQB1AHUALgx1AHUAdQB1AHUAdQA2DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AD4MdQBGDHUAdQB1AHUAdQB1AEkMdQB1AHUAdQB1AFAMMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQBYDHUAdQB1AF8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUA+wMVBGcMMAAwAHwBbwx1AHcMfwyHDI8MMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAYABgAJcMMAAwADAAdQB1AJ8MlQClDDAAMACtDCwHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsB7UMLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHdQB1AHUAdQB1AHUAdQB1AHUAdQB1AHUAdQB1AA0EMAC9DDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAsBywHLAcsBywHLAcsBywHLQcwAMEMyAwsBywHLAcsBywHLAcsBywHLAcsBywHzAwwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwAHUAdQB1ANQM2QzhDDAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMABgAGAAYABgAGAAYABgAOkMYADxDGAA+AwADQYNYABhCWAAYAAODTAAMAAwADAAFg1gAGAAHg37AzAAMAAwADAAYABgACYNYAAsDTQNPA1gAEMNPg1LDWAAYABgAGAAYABgAGAAYABgAGAAUg1aDYsGVglhDV0NcQBnDW0NdQ15DWAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAlQCBDZUAiA2PDZcNMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAnw2nDTAAMAAwADAAMAAwAHUArw23DTAAMAAwADAAMAAwADAAMAAwADAAMAB1AL8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAB1AHUAdQB1AHUAdQDHDTAAYABgAM8NMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA1w11ANwNMAAwAD0B5A0wADAAMAAwADAAMADsDfQN/A0EDgwOFA4wABsOMAAwADAAMAAwADAAMAAwANIG0gbSBtIG0gbSBtIG0gYjDigOwQUuDsEFMw7SBjoO0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGQg5KDlIOVg7SBtIGXg5lDm0OdQ7SBtIGfQ6EDooOjQ6UDtIGmg6hDtIG0gaoDqwO0ga0DrwO0gZgAGAAYADEDmAAYAAkBtIGzA5gANIOYADaDokO0gbSBt8O5w7SBu8O0gb1DvwO0gZgAGAAxA7SBtIG0gbSBtIGYABgAGAAYAAED2AAsAUMD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHJA8sBywHLAcsBywHLAccDywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywPLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAc0D9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAccD9IG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIGFA8sBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHLAcsBywHPA/SBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gbSBtIG0gYUD0QPlQCVAJUAMAAwADAAMACVAJUAlQCVAJUAlQCVAEwPMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAA//8EAAQABAAEAAQABAAEAAQABAANAAMAAQABAAIABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQACgATABcAHgAbABoAHgAXABYAEgAeABsAGAAPABgAHABLAEsASwBLAEsASwBLAEsASwBLABgAGAAeAB4AHgATAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABYAGwASAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWAA0AEQAeAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAFAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJABYAGgAbABsAGwAeAB0AHQAeAE8AFwAeAA0AHgAeABoAGwBPAE8ADgBQAB0AHQAdAE8ATwAXAE8ATwBPABYAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AHgAeAFAATwBAAE8ATwBPAEAATwBQAFAATwBQAB4AHgAeAB4AHgAeAB0AHQAdAB0AHgAdAB4ADgBQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgBQAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAJAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAkACQAJAAkACQAJAAkABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAFAAHgAeAB4AKwArAFAAUABQAFAAGABQACsAKwArACsAHgAeAFAAHgBQAFAAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUAAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAYAA0AKwArAB4AHgAbACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAB4ABAAEAB4ABAAEABMABAArACsAKwArACsAKwArACsAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAKwArACsAKwBWAFYAVgBWAB4AHgArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AGgAaABoAGAAYAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQAEwAEACsAEwATAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABLAEsASwBLAEsASwBLAEsASwBLABoAGQAZAB4AUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQABMAUAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABABQAFAABAAEAB4ABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUAAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAFAABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQAUABQAB4AHgAYABMAUAArACsABAAbABsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAFAABAAEAAQABAAEAFAABAAEAAQAUAAEAAQABAAEAAQAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArACsAHgArAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAUAAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEAA0ADQBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUAArACsAKwBQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABABQACsAKwArACsAKwArACsAKwAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUAAaABoAUABQAFAAUABQAEwAHgAbAFAAHgAEACsAKwAEAAQABAArAFAAUABQAFAAUABQACsAKwArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQACsAUABQACsAKwAEACsABAAEAAQABAAEACsAKwArACsABAAEACsAKwAEAAQABAArACsAKwAEACsAKwArACsAKwArACsAUABQAFAAUAArAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLAAQABABQAFAAUAAEAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsAKwAEAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAArACsAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AGwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAKwArACsAKwArAAQABAAEACsAKwArACsAUABQACsAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAAQAUAArAFAAUABQAFAAUABQACsAKwArAFAAUABQACsAUABQAFAAUAArACsAKwBQAFAAKwBQACsAUABQACsAKwArAFAAUAArACsAKwBQAFAAUAArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArAAQABAAEAAQABAArACsAKwAEAAQABAArAAQABAAEAAQAKwArAFAAKwArACsAKwArACsABAArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAHgAeAB4AHgAeAB4AGwAeACsAKwArACsAKwAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAUABQAFAAKwArACsAKwArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwAOAFAAUABQAFAAUABQAFAAHgBQAAQABAAEAA4AUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAKwArAAQAUAAEAAQABAAEAAQABAAEACsABAAEAAQAKwAEAAQABAAEACsAKwArACsAKwArACsABAAEACsAKwArACsAKwArACsAUAArAFAAUAAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAFAABAAEAAQABAAEAAQABAArAAQABAAEACsABAAEAAQABABQAB4AKwArACsAKwBQAFAAUAAEAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQABoAUABQAFAAUABQAFAAKwAEAAQABAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQACsAUAArACsAUABQAFAAUABQAFAAUAArACsAKwAEACsAKwArACsABAAEAAQABAAEAAQAKwAEACsABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArAAQABAAeACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAXAAqACoAKgAqACoAKgAqACsAKwArACsAGwBcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAeAEsASwBLAEsASwBLAEsASwBLAEsADQANACsAKwArACsAKwBcAFwAKwBcACsAXABcAFwAXABcACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAXAArAFwAXABcAFwAXABcAFwAXABcAFwAKgBcAFwAKgAqACoAKgAqACoAKgAqACoAXAArACsAXABcAFwAXABcACsAXAArACoAKgAqACoAKgAqACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwBcAFwAXABcAFAADgAOAA4ADgAeAA4ADgAJAA4ADgANAAkAEwATABMAEwATAAkAHgATAB4AHgAeAAQABAAeAB4AHgAeAB4AHgBLAEsASwBLAEsASwBLAEsASwBLAFAAUABQAFAAUABQAFAAUABQAFAADQAEAB4ABAAeAAQAFgARABYAEQAEAAQAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQADQAEAAQABAAEAAQADQAEAAQAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAA0ADQAeAB4AHgAeAB4AHgAEAB4AHgAeAB4AHgAeACsAHgAeAA4ADgANAA4AHgAeAB4AHgAeAAkACQArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgBcAEsASwBLAEsASwBLAEsASwBLAEsADQANAB4AHgAeAB4AXABcAFwAXABcAFwAKgAqACoAKgBcAFwAXABcACoAKgAqAFwAKgAqACoAXABcACoAKgAqACoAKgAqACoAXABcAFwAKgAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKgAqAFwAKgBLAEsASwBLAEsASwBLAEsASwBLACoAKgAqACoAKgAqAFAAUABQAFAAUABQACsAUAArACsAKwArACsAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAKwBQACsAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsABAAEAAQAHgANAB4AHgAeAB4AHgAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUAArACsADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAWABEAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQANAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAANAA0AKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUAArAAQABAArACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqAA0ADQAVAFwADQAeAA0AGwBcACoAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwAeAB4AEwATAA0ADQAOAB4AEwATAB4ABAAEAAQACQArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAHgArACsAKwATABMASwBLAEsASwBLAEsASwBLAEsASwBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAXABcAFwAXABcACsAKwArACsAKwArACsAKwArACsAKwBcAFwAXABcAFwAXABcAFwAXABcAFwAXAArACsAKwArAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAXAArACsAKwAqACoAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAArACsAHgAeAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcACoAKgAqACoAKgAqACoAKgAqACoAKwAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKwArAAQASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACoAKgAqACoAKgAqACoAXAAqACoAKgAqACoAKgArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABABQAFAAUABQAFAAUABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwANAA0AHgANAA0ADQANAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAEAAQABAAEAAQAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwAeAB4AHgAeAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArAA0ADQANAA0ADQBLAEsASwBLAEsASwBLAEsASwBLACsAKwArAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAA0ADQBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUAAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArAAQABAAEAB4ABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAAQAUABQAFAAUABQAFAABABQAFAABAAEAAQAUAArACsAKwArACsABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQACsAUAArAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAFAAUABQACsAHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQACsAKwAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQACsAHgAeAB4AHgAeAB4AHgAOAB4AKwANAA0ADQANAA0ADQANAAkADQANAA0ACAAEAAsABAAEAA0ACQANAA0ADAAdAB0AHgAXABcAFgAXABcAFwAWABcAHQAdAB4AHgAUABQAFAANAAEAAQAEAAQABAAEAAQACQAaABoAGgAaABoAGgAaABoAHgAXABcAHQAVABUAHgAeAB4AHgAeAB4AGAAWABEAFQAVABUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ADQAeAA0ADQANAA0AHgANAA0ADQAHAB4AHgAeAB4AKwAEAAQABAAEAAQABAAEAAQABAAEAFAAUAArACsATwBQAFAAUABQAFAAHgAeAB4AFgARAE8AUABPAE8ATwBPAFAAUABQAFAAUAAeAB4AHgAWABEAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArABsAGwAbABsAGwAbABsAGgAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGgAbABsAGwAbABoAGwAbABoAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAHgAeAFAAGgAeAB0AHgBQAB4AGgAeAB4AHgAeAB4AHgAeAB4AHgBPAB4AUAAbAB4AHgBQAFAAUABQAFAAHgAeAB4AHQAdAB4AUAAeAFAAHgBQAB4AUABPAFAAUAAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAHgBQAFAAUABQAE8ATwBQAFAAUABQAFAATwBQAFAATwBQAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAUABQAFAATwBPAE8ATwBPAE8ATwBPAE8ATwBQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABPAB4AHgArACsAKwArAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHQAdAB4AHgAeAB0AHQAeAB4AHQAeAB4AHgAdAB4AHQAbABsAHgAdAB4AHgAeAB4AHQAeAB4AHQAdAB0AHQAeAB4AHQAeAB0AHgAdAB0AHQAdAB0AHQAeAB0AHgAeAB4AHgAeAB0AHQAdAB0AHgAeAB4AHgAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB4AHgAeAB0AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAeAB0AHQAdAB0AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAdAB4AHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAWABEAHgAeAB4AHgAeAB4AHQAeAB4AHgAeAB4AHgAeACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAWABEAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAFAAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAeAB4AHQAdAB0AHQAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB0AHQAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB0AHQAeAB4AHQAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AHQAdAB0AHgAeAB0AHgAeAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlAB4AHQAdAB4AHgAdAB4AHgAeAB4AHQAdAB4AHgAeAB4AJQAlAB0AHQAlAB4AJQAlACUAIAAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAeAB4AHgAeAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHgAdAB0AHQAeAB0AJQAdAB0AHgAdAB0AHgAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHQAdAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAdAB0AHQAdACUAHgAlACUAJQAdACUAJQAdAB0AHQAlACUAHQAdACUAHQAdACUAJQAlAB4AHQAeAB4AHgAeAB0AHQAlAB0AHQAdAB0AHQAdACUAJQAlACUAJQAdACUAJQAgACUAHQAdACUAJQAlACUAJQAlACUAJQAeAB4AHgAlACUAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB0AHgAeAB4AFwAXABcAFwAXABcAHgATABMAJQAeAB4AHgAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARABYAEQAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAWABEAFgARABYAEQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAWABEAFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AFgARAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAdAB0AHQAdAB0AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAFAAUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAEAAQABAAeAB4AKwArACsAKwArABMADQANAA0AUAATAA0AUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUAANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAA0ADQANAA0ADQANAA0ADQAeAA0AFgANAB4AHgAXABcAHgAeABcAFwAWABEAFgARABYAEQAWABEADQANAA0ADQATAFAADQANAB4ADQANAB4AHgAeAB4AHgAMAAwADQANAA0AHgANAA0AFgANAA0ADQANAA0ADQANAA0AHgANAB4ADQANAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArACsAKwArACsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArAA0AEQARACUAJQBHAFcAVwAWABEAFgARABYAEQAWABEAFgARACUAJQAWABEAFgARABYAEQAWABEAFQAWABEAEQAlAFcAVwBXAFcAVwBXAFcAVwBXAAQABAAEAAQABAAEACUAVwBXAFcAVwA2ACUAJQBXAFcAVwBHAEcAJQAlACUAKwBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBRAFcAUQBXAFEAVwBXAFcAVwBXAFcAUQBXAFcAVwBXAFcAVwBRAFEAKwArAAQABAAVABUARwBHAFcAFQBRAFcAUQBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFEAVwBRAFcAUQBXAFcAVwBXAFcAVwBRAFcAVwBXAFcAVwBXAFEAUQBXAFcAVwBXABUAUQBHAEcAVwArACsAKwArACsAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwAlACUAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACsAKwArACsAKwArACsAKwArACsAKwArAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBPAE8ATwBPAE8ATwBPAE8AJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADQATAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABLAEsASwBLAEsASwBLAEsASwBLAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAABAAEAAQABAAeAAQABAAEAAQABAAEAAQABAAEAAQAHgBQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUABQAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAeAA0ADQANAA0ADQArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AUAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAB4AHgAeAB4AHgAeAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AUABQAFAAUABQAFAAUABQAFAAUABQAAQAUABQAFAABABQAFAAUABQAAQAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAeAB4AHgAeAAQAKwArACsAUABQAFAAUABQAFAAHgAeABoAHgArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAADgAOABMAEwArACsAKwArACsAKwArACsABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwANAA0ASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUAAeAB4AHgBQAA4AUABQAAQAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArAB4AWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYACsAKwArAAQAHgAeAB4AHgAeAB4ADQANAA0AHgAeAB4AHgArAFAASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArAB4AHgBcAFwAXABcAFwAKgBcAFwAXABcAFwAXABcAFwAXABcAEsASwBLAEsASwBLAEsASwBLAEsAXABcAFwAXABcACsAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAFAAUABQAAQAUABQAFAAUABQAFAAUABQAAQABAArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAHgANAA0ADQBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKgAqACoAXAAqACoAKgBcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXAAqAFwAKgAqACoAXABcACoAKgBcAFwAXABcAFwAKgAqAFwAKgBcACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFwAXABcACoAKgBQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAA0ADQBQAFAAUAAEAAQAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQADQAEAAQAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAVABVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBUAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVAFUAVQBVACsAKwArACsAKwArACsAKwArACsAKwArAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAKwArACsAKwBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAWgBaAFoAKwArACsAKwAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAKwArACsAKwArAFYABABWAFYAVgBWAFYAVgBWAFYAVgBWAB4AVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgArAFYAVgBWAFYAVgArAFYAKwBWAFYAKwBWAFYAKwBWAFYAVgBWAFYAVgBWAFYAVgBWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAEQAWAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAaAB4AKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAGAARABEAGAAYABMAEwAWABEAFAArACsAKwArACsAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACUAJQAlACUAJQAWABEAFgARABYAEQAWABEAFgARABYAEQAlACUAFgARACUAJQAlACUAJQAlACUAEQAlABEAKwAVABUAEwATACUAFgARABYAEQAWABEAJQAlACUAJQAlACUAJQAlACsAJQAbABoAJQArACsAKwArAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAcAKwATACUAJQAbABoAJQAlABYAEQAlACUAEQAlABEAJQBXAFcAVwBXAFcAVwBXAFcAVwBXABUAFQAlACUAJQATACUAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXABYAJQARACUAJQAlAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAWACUAEQAlABYAEQARABYAEQARABUAVwBRAFEAUQBRAFEAUQBRAFEAUQBRAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAEcARwArACsAVwBXAFcAVwBXAFcAKwArAFcAVwBXAFcAVwBXACsAKwBXAFcAVwBXAFcAVwArACsAVwBXAFcAKwArACsAGgAbACUAJQAlABsAGwArAB4AHgAeAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwAEAAQABAAQAB0AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsADQANAA0AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAA0AUABQAFAAUAArACsAKwArAFAAUABQAFAAUABQAFAAUAANAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwArAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwBQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwANAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAB4AUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAUABQAFAAUABQAAQABAAEACsABAAEACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAKwBQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAA0ADQANAA0ADQANAA0ADQAeACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAArACsAKwArAFAAUABQAFAAUAANAA0ADQANAA0ADQAUACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsADQANAA0ADQANAA0ADQBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAB4AHgAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArAAQABAANACsAKwBQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAB4AHgAeAB4AHgArACsAKwArACsAKwAEAAQABAAEAAQABAAEAA0ADQAeAB4AHgAeAB4AKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwAeACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEACsASwBLAEsASwBLAEsASwBLAEsASwANAA0ADQANAFAABAAEAFAAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAeAA4AUAArACsAKwArACsAKwArACsAKwAEAFAAUABQAFAADQANAB4ADQAEAAQABAAEAB4ABAAEAEsASwBLAEsASwBLAEsASwBLAEsAUAAOAFAADQANAA0AKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAANAA0AHgANAA0AHgAEACsAUABQAFAAUABQAFAAUAArAFAAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAA0AKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsABAAEAAQABAArAFAAUABQAFAAUABQAFAAUAArACsAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQACsABAAEAFAABAAEAAQABAAEAAQABAArACsABAAEACsAKwAEAAQABAArACsAUAArACsAKwArACsAKwAEACsAKwArACsAKwBQAFAAUABQAFAABAAEACsAKwAEAAQABAAEAAQABAAEACsAKwArAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsABAAEAAQABAAEAAQABABQAFAAUABQAA0ADQANAA0AHgBLAEsASwBLAEsASwBLAEsASwBLAA0ADQArAB4ABABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAFAAUAAeAFAAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABAAEAAQADgANAA0AEwATAB4AHgAeAA0ADQANAA0ADQANAA0ADQANAA0ADQANAA0ADQANAFAAUABQAFAABAAEACsAKwAEAA0ADQAeAFAAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAFAAKwArACsAKwArACsAKwBLAEsASwBLAEsASwBLAEsASwBLACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAKwArACoAKgAqACoAKgAqACoAKgAqACoAKgAqACoAKgAqACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBcAFwADQANAA0AKgBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAKwArAFAAKwArAFAAUABQAFAAUABQAFAAUAArAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQAKwAEAAQAKwArAAQABAAEAAQAUAAEAFAABAAEAA0ADQANACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAArACsABAAEAAQABAAEAAQABABQAA4AUAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAABAAEAAQABAAEAAQABAAEAAQABABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAFAABAAEAAQABAAOAB4ADQANAA0ADQAOAB4ABAArACsAKwArACsAKwArACsAUAAEAAQABAAEAAQABAAEAAQABAAEAAQAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAA0ADQANAFAADgAOAA4ADQANACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAEAAQABAAEACsABAAEAAQABAAEAAQABAAEAFAADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAOABMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQACsAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAArACsAKwAEACsABAAEACsABAAEAAQABAAEAAQABABQAAQAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAUABQAFAAUABQAFAAKwBQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAUAArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAABAAEAAQABAAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAaABoAGgAaAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArAA0AUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsADQANAA0ADQANACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABIAEgAQwBDAEMAUABQAFAAUABDAFAAUABQAEgAQwBIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAASABDAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwAJAAkACQAJAAkACQAJABYAEQArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABIAEMAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwANAA0AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArAAQABAAEAAQABAANACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEAA0ADQANAB4AHgAeAB4AHgAeAFAAUABQAFAADQAeACsAKwArACsAKwArACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAANAA0AHgAeACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwAEAFAABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwAEAAQABAAEAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAARwBHABUARwAJACsAKwArACsAKwArACsAKwArACsAKwAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUQBRAFEAKwArACsAKwArACsAKwArACsAKwArACsAKwBRAFEAUQBRACsAKwArACsAKwArACsAKwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUAArACsAHgAEAAQADQAEAAQABAAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAAQABAAEAAQABAAeAB4AHgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAB4AHgAEAAQABAAEAAQABAAEAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4ABAAEAAQAHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwArACsAKwArACsAKwArACsAKwArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAKwArAFAAKwArAFAAUAArACsAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACsAUAArAFAAUABQAFAAUABQAFAAKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwBQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAHgAeAFAAUABQAFAAUAArAFAAKwArACsAUABQAFAAUABQAFAAUAArAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAB4AHgAeAB4AHgAeAB4AHgAeACsAKwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAeAB4AHgAeAB4AHgAeAB4ABAAeAB4AHgAeAB4AHgAeAB4AHgAeAAQAHgAeAA0ADQANAA0AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQAKwAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArAAQABAAEAAQABAAEAAQAKwAEAAQAKwAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwAEAAQABAAEAAQABAAEAFAAUABQAFAAUABQAFAAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwBQAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArABsAUABQAFAAUABQACsAKwBQAFAAUABQAFAAUABQAFAAUAAEAAQABAAEAAQABAAEACsAKwArACsAKwArACsAKwArAB4AHgAeAB4ABAAEAAQABAAEAAQABABQACsAKwArACsASwBLAEsASwBLAEsASwBLAEsASwArACsAKwArABYAFgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAGgBQAFAAUAAaAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAeAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQACsAKwBQAFAAUABQACsAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUAArACsAKwArACsAKwBQACsAKwArACsAUAArAFAAKwBQACsAUABQAFAAKwBQAFAAKwBQACsAKwBQACsAUAArAFAAKwBQACsAUAArAFAAUAArAFAAKwArAFAAUABQAFAAKwBQAFAAUABQAFAAUABQACsAUABQAFAAUAArAFAAUABQAFAAKwBQACsAUABQAFAAUABQAFAAUABQAFAAUAArAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAArACsAKwArACsAUABQAFAAKwBQAFAAUABQAFAAKwBQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwAeAB4AKwArACsAKwArACsAKwArACsAKwArACsAKwArAE8ATwBPAE8ATwBPAE8ATwBPAE8ATwBPAE8AJQAlACUAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHgAeAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB4AHgAeACUAJQAlAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAKQApACkAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAlACUAJQAlACUAHgAlACUAJQAlACUAIAAgACAAJQAlACAAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACEAIQAhACEAIQAlACUAIAAgACUAJQAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlACUAIAAlACUAJQAlACAAIAAgACUAIAAgACAAJQAlACUAJQAlACUAJQAgACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAlAB4AJQAeACUAJQAlACUAJQAgACUAJQAlACUAHgAlAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAgACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACAAIAAgACAAIAAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeABcAFwAXABUAFQAVAB4AHgAeAB4AJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAgACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlACUAJQAeAB4AHgAeAB4AHgAeAB4AHgAeACUAJQAlACUAJQAlAB4AHgAeAB4AHgAeAB4AHgAlACUAJQAlACUAJQAlACUAHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAgACUAJQAgACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAJQAlACUAJQAlACUAIAAlACUAJQAlACUAJQAlACUAJQAgACAAIAAgACAAIAAgACAAIAAgACUAJQAgACAAIAAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACAAIAAlACAAIAAlACAAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAgACAAIAAlACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAJQAlAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AKwAeAB4AHgAeAB4AHgAeAB4AHgAeAB4AHgArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAEsASwBLAEsASwBLAEsASwBLAEsAKwArACsAKwArACsAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwArAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwAlACUAJQAlACUAJQAlACUAJQAlACUAVwBXACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAJQAlACUAKwAEACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArACsAKwArAA=="),L=Array.isArray(m)?function(A){for(var e=A.length,t=[],r=0;r=this._value.length?-1:this._value[A]},XA.prototype.consumeUnicodeRangeToken=function(){for(var A=[],e=this.consumeCodePoint();lA(e)&&A.length<6;)A.push(e),e=this.consumeCodePoint();for(var t=!1;63===e&&A.length<6;)A.push(e),e=this.consumeCodePoint(),t=!0;if(t)return{type:30,start:parseInt(g.apply(void 0,A.map(function(A){return 63===A?48:A})),16),end:parseInt(g.apply(void 0,A.map(function(A){return 63===A?70:A})),16)};var r=parseInt(g.apply(void 0,A),16);if(45===this.peekCodePoint(0)&&lA(this.peekCodePoint(1))){this.consumeCodePoint();for(var e=this.consumeCodePoint(),B=[];lA(e)&&B.length<6;)B.push(e),e=this.consumeCodePoint();return{type:30,start:r,end:parseInt(g.apply(void 0,B),16)}}return{type:30,start:r,end:r}},XA.prototype.consumeIdentLikeToken=function(){var A=this.consumeName();return"url"===A.toLowerCase()&&40===this.peekCodePoint(0)?(this.consumeCodePoint(),this.consumeUrlToken()):40===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:19,value:A}):{type:20,value:A}},XA.prototype.consumeUrlToken=function(){var A=[];if(this.consumeWhiteSpace(),-1===this.peekCodePoint(0))return{type:22,value:""};var e,t=this.peekCodePoint(0);if(39===t||34===t){t=this.consumeStringToken(this.consumeCodePoint());return 0===t.type&&(this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0))?(this.consumeCodePoint(),{type:22,value:t.value}):(this.consumeBadUrlRemnants(),xA)}for(;;){var r=this.consumeCodePoint();if(-1===r||41===r)return{type:22,value:g.apply(void 0,A)};if(CA(r))return this.consumeWhiteSpace(),-1===this.peekCodePoint(0)||41===this.peekCodePoint(0)?(this.consumeCodePoint(),{type:22,value:g.apply(void 0,A)}):(this.consumeBadUrlRemnants(),xA);if(34===r||39===r||40===r||(0<=(e=r)&&e<=8||11===e||14<=e&&e<=31||127===e))return this.consumeBadUrlRemnants(),xA;if(92===r){if(!hA(r,this.peekCodePoint(0)))return this.consumeBadUrlRemnants(),xA;A.push(this.consumeEscapedCodePoint())}else A.push(r)}},XA.prototype.consumeWhiteSpace=function(){for(;CA(this.peekCodePoint(0));)this.consumeCodePoint()},XA.prototype.consumeBadUrlRemnants=function(){for(;;){var A=this.consumeCodePoint();if(41===A||-1===A)return;hA(A,this.peekCodePoint(0))&&this.consumeEscapedCodePoint()}},XA.prototype.consumeStringSlice=function(A){for(var e="";0>8,r=255&A>>16,A=255&A>>24;return e<255?"rgba("+A+","+r+","+t+","+e/255+")":"rgb("+A+","+r+","+t+")"}function Qe(A,e){if(17===A.type)return A.number;if(16!==A.type)return 0;var t=3===e?1:255;return 3===e?A.number/100*t:Math.round(A.number/100*t)}var ce=function(A,e){return 11===e&&12===A.type||(28===e&&29===A.type||2===e&&3===A.type)},ae={type:17,number:0,flags:4},ge={type:16,number:50,flags:4},we={type:16,number:100,flags:4},Ue=function(A,e){if(16===A.type)return A.number/100*e;if(WA(A))switch(A.unit){case"rem":case"em":return 16*A.number;default:return A.number}return A.number},le=function(A,e){if(15===e.type)switch(e.unit){case"deg":return Math.PI*e.number/180;case"grad":return Math.PI/200*e.number;case"rad":return e.number;case"turn":return 2*Math.PI*e.number}throw new Error("Unsupported angle type")},Ce=function(A){return Math.PI*A/180},ue=function(A,e){if(18===e.type){var t=me[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported color function "'+e.name+'"');return t(A,e.values)}if(5===e.type){if(3===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),1)}if(4===e.value.length){var r=e.value.substring(0,1),B=e.value.substring(1,2),n=e.value.substring(2,3),s=e.value.substring(3,4);return Fe(parseInt(r+r,16),parseInt(B+B,16),parseInt(n+n,16),parseInt(s+s,16)/255)}if(6===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),1)}if(8===e.value.length){r=e.value.substring(0,2),B=e.value.substring(2,4),n=e.value.substring(4,6),s=e.value.substring(6,8);return Fe(parseInt(r,16),parseInt(B,16),parseInt(n,16),parseInt(s,16)/255)}}if(20===e.type){e=Le[e.value.toUpperCase()];if(void 0!==e)return e}return Le.TRANSPARENT},Fe=function(A,e,t,r){return(A<<24|e<<16|t<<8|Math.round(255*r)<<0)>>>0},he=function(A,e){e=e.filter($A);if(3===e.length){var t=e.map(Qe),r=t[0],B=t[1],t=t[2];return Fe(r,B,t,1)}if(4!==e.length)return 0;e=e.map(Qe),r=e[0],B=e[1],t=e[2],e=e[3];return Fe(r,B,t,e)};function de(A,e,t){return t<0&&(t+=1),1<=t&&--t,t<1/6?(e-A)*t*6+A:t<.5?e:t<2/3?6*(e-A)*(2/3-t)+A:A}function fe(A,e){return ue(A,JA.create(e).parseComponentValue())}function He(A,e){return A=ue(A,e[0]),(e=e[1])&&te(e)?{color:A,stop:e}:{color:A,stop:null}}function pe(A,t){var e=A[0],r=A[A.length-1];null===e.stop&&(e.stop=ae),null===r.stop&&(r.stop=we);for(var B=[],n=0,s=0;sA.optimumDistance)?{optimumCorner:e,optimumDistance:r}:A},{optimumDistance:s?1/0:-1/0,optimumCorner:null}).optimumCorner}var Ke=function(A,e){var t=e.filter($A),r=t[0],B=t[1],n=t[2],e=t[3],t=(17===r.type?Ce(r.number):le(A,r))/(2*Math.PI),A=te(B)?B.number/100:0,r=te(n)?n.number/100:0,B=void 0!==e&&te(e)?Ue(e,1):1;if(0==A)return Fe(255*r,255*r,255*r,1);n=r<=.5?r*(1+A):r+A-r*A,e=2*r-n,A=de(e,n,t+1/3),r=de(e,n,t),t=de(e,n,t-1/3);return Fe(255*A,255*r,255*t,B)},me={hsl:Ke,hsla:Ke,rgb:he,rgba:he},Le={ALICEBLUE:4042850303,ANTIQUEWHITE:4209760255,AQUA:16777215,AQUAMARINE:2147472639,AZURE:4043309055,BEIGE:4126530815,BISQUE:4293182719,BLACK:255,BLANCHEDALMOND:4293643775,BLUE:65535,BLUEVIOLET:2318131967,BROWN:2771004159,BURLYWOOD:3736635391,CADETBLUE:1604231423,CHARTREUSE:2147418367,CHOCOLATE:3530104575,CORAL:4286533887,CORNFLOWERBLUE:1687547391,CORNSILK:4294499583,CRIMSON:3692313855,CYAN:16777215,DARKBLUE:35839,DARKCYAN:9145343,DARKGOLDENROD:3095837695,DARKGRAY:2846468607,DARKGREEN:6553855,DARKGREY:2846468607,DARKKHAKI:3182914559,DARKMAGENTA:2332068863,DARKOLIVEGREEN:1433087999,DARKORANGE:4287365375,DARKORCHID:2570243327,DARKRED:2332033279,DARKSALMON:3918953215,DARKSEAGREEN:2411499519,DARKSLATEBLUE:1211993087,DARKSLATEGRAY:793726975,DARKSLATEGREY:793726975,DARKTURQUOISE:13554175,DARKVIOLET:2483082239,DEEPPINK:4279538687,DEEPSKYBLUE:12582911,DIMGRAY:1768516095,DIMGREY:1768516095,DODGERBLUE:512819199,FIREBRICK:2988581631,FLORALWHITE:4294635775,FORESTGREEN:579543807,FUCHSIA:4278255615,GAINSBORO:3705462015,GHOSTWHITE:4177068031,GOLD:4292280575,GOLDENROD:3668254975,GRAY:2155905279,GREEN:8388863,GREENYELLOW:2919182335,GREY:2155905279,HONEYDEW:4043305215,HOTPINK:4285117695,INDIANRED:3445382399,INDIGO:1258324735,IVORY:4294963455,KHAKI:4041641215,LAVENDER:3873897215,LAVENDERBLUSH:4293981695,LAWNGREEN:2096890111,LEMONCHIFFON:4294626815,LIGHTBLUE:2916673279,LIGHTCORAL:4034953471,LIGHTCYAN:3774873599,LIGHTGOLDENRODYELLOW:4210742015,LIGHTGRAY:3553874943,LIGHTGREEN:2431553791,LIGHTGREY:3553874943,LIGHTPINK:4290167295,LIGHTSALMON:4288707327,LIGHTSEAGREEN:548580095,LIGHTSKYBLUE:2278488831,LIGHTSLATEGRAY:2005441023,LIGHTSLATEGREY:2005441023,LIGHTSTEELBLUE:2965692159,LIGHTYELLOW:4294959359,LIME:16711935,LIMEGREEN:852308735,LINEN:4210091775,MAGENTA:4278255615,MAROON:2147483903,MEDIUMAQUAMARINE:1724754687,MEDIUMBLUE:52735,MEDIUMORCHID:3126187007,MEDIUMPURPLE:2473647103,MEDIUMSEAGREEN:1018393087,MEDIUMSLATEBLUE:2070474495,MEDIUMSPRINGGREEN:16423679,MEDIUMTURQUOISE:1221709055,MEDIUMVIOLETRED:3340076543,MIDNIGHTBLUE:421097727,MINTCREAM:4127193855,MISTYROSE:4293190143,MOCCASIN:4293178879,NAVAJOWHITE:4292783615,NAVY:33023,OLDLACE:4260751103,OLIVE:2155872511,OLIVEDRAB:1804477439,ORANGE:4289003775,ORANGERED:4282712319,ORCHID:3664828159,PALEGOLDENROD:4008225535,PALEGREEN:2566625535,PALETURQUOISE:2951671551,PALEVIOLETRED:3681588223,PAPAYAWHIP:4293907967,PEACHPUFF:4292524543,PERU:3448061951,PINK:4290825215,PLUM:3718307327,POWDERBLUE:2967529215,PURPLE:2147516671,REBECCAPURPLE:1714657791,RED:4278190335,ROSYBROWN:3163525119,ROYALBLUE:1097458175,SADDLEBROWN:2336560127,SALMON:4202722047,SANDYBROWN:4104413439,SEAGREEN:780883967,SEASHELL:4294307583,SIENNA:2689740287,SILVER:3233857791,SKYBLUE:2278484991,SLATEBLUE:1784335871,SLATEGRAY:1887473919,SLATEGREY:1887473919,SNOW:4294638335,SPRINGGREEN:16744447,STEELBLUE:1182971135,TAN:3535047935,TEAL:8421631,THISTLE:3636451583,TOMATO:4284696575,TRANSPARENT:0,TURQUOISE:1088475391,VIOLET:4001558271,WHEAT:4125012991,WHITE:4294967295,WHITESMOKE:4126537215,YELLOW:4294902015,YELLOWGREEN:2597139199},be={name:"background-clip",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},De={name:"background-color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ke=function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&-1!==["top","left","right","bottom"].indexOf(e.value))return void(r=se(A));if(ne(e))return void(r=(le(t,e)+Ce(270))%Ce(360))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},ve="closest-side",xe="farthest-side",Me="closest-corner",Se="farthest-corner",Te="ellipse",Ge="contain",he=function(r,A){var B=0,n=3,s=[],o=[];return Ae(A).forEach(function(A,e){var t=!0;0===e?t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"center":return o.push(ge),!1;case"top":case"left":return o.push(ae),!1;case"right":case"bottom":return o.push(we),!1}else if(te(e)||ee(e))return o.push(e),!1;return A},t):1===e&&(t=A.reduce(function(A,e){if(_A(e))switch(e.value){case"circle":return B=0,!1;case Te:return!(B=1);case Ge:case ve:return n=0,!1;case xe:return!(n=1);case Me:return!(n=2);case"cover":case Se:return!(n=3)}else if(ee(e)||te(e))return(n=!Array.isArray(n)?[]:n).push(e),!1;return A},t)),t&&(A=He(r,A),s.push(A))}),{size:n,shape:B,stops:s,position:o,type:2}},Oe=function(A,e){if(22===e.type){var t={url:e.value,type:0};return A.cache.addImage(e.value),t}if(18!==e.type)throw new Error("Unsupported image type "+e.type);t=ke[e.name];if(void 0===t)throw new Error('Attempting to parse an unsupported image function "'+e.name+'"');return t(A,e.values)};var Ve,ke={"linear-gradient":function(t,A){var r=Ce(180),B=[];return Ae(A).forEach(function(A,e){if(0===e){e=A[0];if(20===e.type&&"to"===e.value)return void(r=se(A));if(ne(e))return void(r=le(t,e))}A=He(t,A);B.push(A)}),{angle:r,stops:B,type:1}},"-moz-linear-gradient":Ke,"-ms-linear-gradient":Ke,"-o-linear-gradient":Ke,"-webkit-linear-gradient":Ke,"radial-gradient":function(B,A){var n=0,s=3,o=[],i=[];return Ae(A).forEach(function(A,e){var t,r=!0;0===e&&(t=!1,r=A.reduce(function(A,e){if(t)if(_A(e))switch(e.value){case"center":return i.push(ge),A;case"top":case"left":return i.push(ae),A;case"right":case"bottom":return i.push(we),A}else(te(e)||ee(e))&&i.push(e);else if(_A(e))switch(e.value){case"circle":return n=0,!1;case Te:return!(n=1);case"at":return!(t=!0);case ve:return s=0,!1;case"cover":case xe:return!(s=1);case Ge:case Me:return!(s=2);case Se:return!(s=3)}else if(ee(e)||te(e))return(s=!Array.isArray(s)?[]:s).push(e),!1;return A},r)),r&&(A=He(B,A),o.push(A))}),{size:s,shape:n,stops:o,position:i,type:2}},"-moz-radial-gradient":he,"-ms-radial-gradient":he,"-o-radial-gradient":he,"-webkit-radial-gradient":he,"-webkit-gradient":function(r,A){var e=Ce(180),B=[],n=1;return Ae(A).forEach(function(A,e){var t,A=A[0];if(0===e){if(_A(A)&&"linear"===A.value)return void(n=1);if(_A(A)&&"radial"===A.value)return void(n=2)}18===A.type&&("from"===A.name?(t=ue(r,A.values[0]),B.push({stop:ae,color:t})):"to"===A.name?(t=ue(r,A.values[0]),B.push({stop:we,color:t})):"color-stop"!==A.name||2===(A=A.values.filter($A)).length&&(t=ue(r,A[1]),A=A[0],ZA(A)&&B.push({stop:{type:16,number:100*A.number,flags:A.flags},color:t})))}),1===n?{angle:(e+Ce(180))%Ce(360),stops:B,type:n}:{size:3,shape:0,stops:B,position:[],type:n}}},Re={name:"background-image",initialValue:"none",type:1,prefix:!1,parse:function(e,A){if(0===A.length)return[];var t=A[0];return 20===t.type&&"none"===t.value?[]:A.filter(function(A){return $A(A)&&!(20===(A=A).type&&"none"===A.value||18===A.type&&!ke[A.name])}).map(function(A){return Oe(e,A)})}},Ne={name:"background-origin",initialValue:"border-box",prefix:!1,type:1,parse:function(A,e){return e.map(function(A){if(_A(A))switch(A.value){case"padding-box":return 1;case"content-box":return 2}return 0})}},Pe={name:"background-position",initialValue:"0% 0%",type:1,prefix:!1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(te)}).map(re)}},Xe={name:"background-repeat",initialValue:"repeat",prefix:!1,type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(_A).map(function(A){return A.value}).join(" ")}).map(Je)}},Je=function(A){switch(A){case"no-repeat":return 1;case"repeat-x":case"repeat no-repeat":return 2;case"repeat-y":case"no-repeat repeat":return 3;default:return 0}};(he=Ve=Ve||{}).AUTO="auto",he.CONTAIN="contain";function Ye(A,e){return _A(A)&&"normal"===A.value?1.2*e:17===A.type?e*A.number:te(A)?Ue(A,e):e}var We,Ze,_e={name:"background-size",initialValue:"0",prefix:!(he.COVER="cover"),type:1,parse:function(A,e){return Ae(e).map(function(A){return A.filter(qe)})}},qe=function(A){return _A(A)||te(A)},he=function(A){return{name:"border-"+A+"-color",initialValue:"transparent",prefix:!1,type:3,format:"color"}},je=he("top"),ze=he("right"),$e=he("bottom"),At=he("left"),he=function(A){return{name:"border-radius-"+A,initialValue:"0 0",prefix:!1,type:1,parse:function(A,e){return re(e.filter(te))}}},et=he("top-left"),tt=he("top-right"),rt=he("bottom-right"),Bt=he("bottom-left"),he=function(A){return{name:"border-"+A+"-style",initialValue:"solid",prefix:!1,type:2,parse:function(A,e){switch(e){case"none":return 0;case"dashed":return 2;case"dotted":return 3;case"double":return 4}return 1}}},nt=he("top"),st=he("right"),ot=he("bottom"),it=he("left"),he=function(A){return{name:"border-"+A+"-width",initialValue:"0",type:0,prefix:!1,parse:function(A,e){return WA(e)?e.number:0}}},Qt=he("top"),ct=he("right"),at=he("bottom"),gt=he("left"),wt={name:"color",initialValue:"transparent",prefix:!1,type:3,format:"color"},Ut={name:"direction",initialValue:"ltr",prefix:!1,type:2,parse:function(A,e){return"rtl"!==e?0:1}},lt={name:"display",initialValue:"inline-block",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).reduce(function(A,e){return A|Ct(e.value)},0)}},Ct=function(A){switch(A){case"block":case"-webkit-box":return 2;case"inline":return 4;case"run-in":return 8;case"flow":return 16;case"flow-root":return 32;case"table":return 64;case"flex":case"-webkit-flex":return 128;case"grid":case"-ms-grid":return 256;case"ruby":return 512;case"subgrid":return 1024;case"list-item":return 2048;case"table-row-group":return 4096;case"table-header-group":return 8192;case"table-footer-group":return 16384;case"table-row":return 32768;case"table-cell":return 65536;case"table-column-group":return 131072;case"table-column":return 262144;case"table-caption":return 524288;case"ruby-base":return 1048576;case"ruby-text":return 2097152;case"ruby-base-container":return 4194304;case"ruby-text-container":return 8388608;case"contents":return 16777216;case"inline-block":return 33554432;case"inline-list-item":return 67108864;case"inline-table":return 134217728;case"inline-flex":return 268435456;case"inline-grid":return 536870912}return 0},ut={name:"float",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"left":return 1;case"right":return 2;case"inline-start":return 3;case"inline-end":return 4}return 0}},Ft={name:"letter-spacing",initialValue:"0",prefix:!1,type:0,parse:function(A,e){return!(20===e.type&&"normal"===e.value||17!==e.type&&15!==e.type)?e.number:0}},ht={name:"line-break",initialValue:(he=We=We||{}).NORMAL="normal",prefix:!(he.STRICT="strict"),type:2,parse:function(A,e){return"strict"!==e?We.NORMAL:We.STRICT}},dt={name:"line-height",initialValue:"normal",prefix:!1,type:4},ft={name:"list-style-image",initialValue:"none",type:0,prefix:!1,parse:function(A,e){return 20===e.type&&"none"===e.value?null:Oe(A,e)}},Ht={name:"list-style-position",initialValue:"outside",prefix:!1,type:2,parse:function(A,e){return"inside"!==e?1:0}},pt={name:"list-style-type",initialValue:"none",prefix:!1,type:2,parse:function(A,e){switch(e){case"disc":return 0;case"circle":return 1;case"square":return 2;case"decimal":return 3;case"cjk-decimal":return 4;case"decimal-leading-zero":return 5;case"lower-roman":return 6;case"upper-roman":return 7;case"lower-greek":return 8;case"lower-alpha":return 9;case"upper-alpha":return 10;case"arabic-indic":return 11;case"armenian":return 12;case"bengali":return 13;case"cambodian":return 14;case"cjk-earthly-branch":return 15;case"cjk-heavenly-stem":return 16;case"cjk-ideographic":return 17;case"devanagari":return 18;case"ethiopic-numeric":return 19;case"georgian":return 20;case"gujarati":return 21;case"gurmukhi":case"hebrew":return 22;case"hiragana":return 23;case"hiragana-iroha":return 24;case"japanese-formal":return 25;case"japanese-informal":return 26;case"kannada":return 27;case"katakana":return 28;case"katakana-iroha":return 29;case"khmer":return 30;case"korean-hangul-formal":return 31;case"korean-hanja-formal":return 32;case"korean-hanja-informal":return 33;case"lao":return 34;case"lower-armenian":return 35;case"malayalam":return 36;case"mongolian":return 37;case"myanmar":return 38;case"oriya":return 39;case"persian":return 40;case"simp-chinese-formal":return 41;case"simp-chinese-informal":return 42;case"tamil":return 43;case"telugu":return 44;case"thai":return 45;case"tibetan":return 46;case"trad-chinese-formal":return 47;case"trad-chinese-informal":return 48;case"upper-armenian":return 49;case"disclosure-open":return 50;case"disclosure-closed":return 51;default:return-1}}},he=function(A){return{name:"margin-"+A,initialValue:"0",prefix:!1,type:4}},Et=he("top"),It=he("right"),yt=he("bottom"),Kt=he("left"),mt={name:"overflow",initialValue:"visible",prefix:!1,type:1,parse:function(A,e){return e.filter(_A).map(function(A){switch(A.value){case"hidden":return 1;case"scroll":return 2;case"clip":return 3;case"auto":return 4;default:return 0}})}},Lt={name:"overflow-wrap",initialValue:"normal",prefix:!1,type:2,parse:function(A,e){return"break-word"!==e?"normal":"break-word"}},he=function(A){return{name:"padding-"+A,initialValue:"0",prefix:!1,type:3,format:"length-percentage"}},bt=he("top"),Dt=he("right"),vt=he("bottom"),xt=he("left"),Mt={name:"text-align",initialValue:"left",prefix:!1,type:2,parse:function(A,e){switch(e){case"right":return 2;case"center":case"justify":return 1;default:return 0}}},St={name:"position",initialValue:"static",prefix:!1,type:2,parse:function(A,e){switch(e){case"relative":return 1;case"absolute":return 2;case"fixed":return 3;case"sticky":return 4}return 0}},Tt={name:"text-shadow",initialValue:"none",type:1,prefix:!1,parse:function(n,A){return 1===A.length&&jA(A[0],"none")?[]:Ae(A).map(function(A){for(var e={color:Le.TRANSPARENT,offsetX:ae,offsetY:ae,blur:ae},t=0,r=0;r>5],this.data[e=(e<<2)+(31&A)];if(A<=65535)return e=this.index[2048+(A-55296>>5)],this.data[e=(e<<2)+(31&A)];if(A>11)],e=this.index[e+=A>>5&63],this.data[e=(e<<2)+(31&A)];if(A<=1114111)return this.data[this.highValueIndex]}return this.errorValue},pr);function pr(A,e,t,r,B,n){this.initialValue=A,this.errorValue=e,this.highStart=t,this.highValueIndex=r,this.index=B,this.data=n}for(var Er="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/",Ir="undefined"==typeof Uint8Array?[]:new Uint8Array(256),yr=0;yr>10),s%1024+56320)),(B+1===t||16384>4,i[o++]=(15&t)<<4|r>>2,i[o++]=(3&r)<<6|63&B;return n}(br="AAAAAAAAAAAAEA4AGBkAAFAaAAACAAAAAAAIABAAGAAwADgACAAQAAgAEAAIABAACAAQAAgAEAAIABAACAAQAAgAEAAIABAAQABIAEQATAAIABAACAAQAAgAEAAIABAAVABcAAgAEAAIABAACAAQAGAAaABwAHgAgACIAI4AlgAIABAAmwCjAKgAsAC2AL4AvQDFAMoA0gBPAVYBWgEIAAgACACMANoAYgFkAWwBdAF8AX0BhQGNAZUBlgGeAaMBlQGWAasBswF8AbsBwwF0AcsBYwHTAQgA2wG/AOMBdAF8AekB8QF0AfkB+wHiAHQBfAEIAAMC5gQIAAsCEgIIAAgAFgIeAggAIgIpAggAMQI5AkACygEIAAgASAJQAlgCYAIIAAgACAAKBQoFCgUTBRMFGQUrBSsFCAAIAAgACAAIAAgACAAIAAgACABdAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABoAmgCrwGvAQgAbgJ2AggAHgEIAAgACADnAXsCCAAIAAgAgwIIAAgACAAIAAgACACKAggAkQKZAggAPADJAAgAoQKkAqwCsgK6AsICCADJAggA0AIIAAgACAAIANYC3gIIAAgACAAIAAgACABAAOYCCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAkASoB+QIEAAgACAA8AEMCCABCBQgACABJBVAFCAAIAAgACAAIAAgACAAIAAgACABTBVoFCAAIAFoFCABfBWUFCAAIAAgACAAIAAgAbQUIAAgACAAIAAgACABzBXsFfQWFBYoFigWKBZEFigWKBYoFmAWfBaYFrgWxBbkFCAAIAAgACAAIAAgACAAIAAgACAAIAMEFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAMgFCADQBQgACAAIAAgACAAIAAgACAAIAAgACAAIAO4CCAAIAAgAiQAIAAgACABAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAD0AggACAD8AggACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIANYFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAMDvwAIAAgAJAIIAAgACAAIAAgACAAIAAgACwMTAwgACAB9BOsEGwMjAwgAKwMyAwsFYgE3A/MEPwMIAEUDTQNRAwgAWQOsAGEDCAAIAAgACAAIAAgACABpAzQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFOgU0BTUFNgU3BTgFOQU6BTQFNQU2BTcFOAU5BToFNAU1BTYFNwU4BTkFIQUoBSwFCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABtAwgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABMAEwACAAIAAgACAAIABgACAAIAAgACAC/AAgACAAyAQgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACAAIAAwAAgACAAIAAgACAAIAAgACAAIAAAARABIAAgACAAIABQASAAIAAgAIABwAEAAjgCIABsAqAC2AL0AigDQAtwC+IJIQqVAZUBWQqVAZUBlQGVAZUBlQGrC5UBlQGVAZUBlQGVAZUBlQGVAXsKlQGVAbAK6wsrDGUMpQzlDJUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAZUBlQGVAfAKAAuZA64AtwCJALoC6ADwAAgAuACgA/oEpgO6AqsD+AAIAAgAswMIAAgACAAIAIkAuwP5AfsBwwPLAwgACAAIAAgACADRA9kDCAAIAOED6QMIAAgACAAIAAgACADuA/YDCAAIAP4DyQAIAAgABgQIAAgAXQAOBAgACAAIAAgACAAIABMECAAIAAgACAAIAAgACAD8AAQBCAAIAAgAGgQiBCoECAExBAgAEAEIAAgACAAIAAgACAAIAAgACAAIAAgACAA4BAgACABABEYECAAIAAgATAQYAQgAVAQIAAgACAAIAAgACAAIAAgACAAIAFoECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAOQEIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAB+BAcACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAEABhgSMBAgACAAIAAgAlAQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAwAEAAQABAADAAMAAwADAAQABAAEAAQABAAEAAQABHATAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAdQMIAAgACAAIAAgACAAIAMkACAAIAAgAfQMIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACACFA4kDCAAIAAgACAAIAOcBCAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAIcDCAAIAAgACAAIAAgACAAIAAgACAAIAJEDCAAIAAgACADFAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABgBAgAZgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAbAQCBXIECAAIAHkECAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACABAAJwEQACjBKoEsgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAC6BMIECAAIAAgACAAIAAgACABmBAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAxwQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAGYECAAIAAgAzgQIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBd0FXwUIAOIF6gXxBYoF3gT5BQAGCAaKBYoFigWKBYoFigWKBYoFigWKBYoFigXWBIoFigWKBYoFigWKBYoFigWKBYsFEAaKBYoFigWKBYoFigWKBRQGCACKBYoFigWKBQgACAAIANEECAAIABgGigUgBggAJgYIAC4GMwaKBYoF0wQ3Bj4GigWKBYoFigWKBYoFigWKBYoFigWKBYoFigUIAAgACAAIAAgACAAIAAgAigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWKBYoFigWLBf///////wQABAAEAAQABAAEAAQABAAEAAQAAwAEAAQAAgAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAQADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUAAAAFAAUAAAAFAAUAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUAAQAAAAUABQAFAAUABQAFAAAAAAAFAAUAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAFAAUAAQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAAABwAHAAcAAAAHAAcABwAFAAEAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAcABwAFAAUAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAQABAAAAAAAAAAAAAAAFAAUABQAFAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAHAAcAAAAHAAcAAAAAAAUABQAHAAUAAQAHAAEABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwABAAUABQAFAAUAAAAAAAAAAAAAAAEAAQABAAEAAQABAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABQANAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAEAAQABAAEAAQABAAEAAQABAAEAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAABQAHAAUABQAFAAAAAAAAAAcABQAFAAUABQAFAAQABAAEAAQABAAEAAQABAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAEAAQABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUAAAAFAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAUAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAcABwAFAAcABwAAAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUABwAHAAUABQAFAAUAAAAAAAcABwAAAAAABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAABQAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAAAAAAAAAAABQAFAAAAAAAFAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAFAAUABQAFAAUAAAAFAAUABwAAAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABwAFAAUABQAFAAAAAAAHAAcAAAAAAAcABwAFAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAAAAAAAAAHAAcABwAAAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAABQAHAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAUABQAFAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAHAAcABQAHAAcAAAAFAAcABwAAAAcABwAFAAUAAAAAAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAFAAcABwAFAAUABQAAAAUAAAAHAAcABwAHAAcABwAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAHAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAABwAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAUAAAAFAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABwAFAAUABQAFAAUAAAAFAAUAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABwAFAAUABQAFAAUABQAAAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABQAFAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABQAFAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAHAAUABQAFAAUABQAFAAUABwAHAAcABwAHAAcABwAHAAUABwAHAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABwAHAAcABwAFAAUABwAHAAcAAAAAAAAAAAAHAAcABQAHAAcABwAHAAcABwAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAcABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAUABQAFAAUABQAFAAUAAAAFAAAABQAAAAAABQAFAAUABQAFAAUABQAFAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAFAAUAAAAAAAUABQAFAAUABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABwAFAAcABwAHAAcABwAFAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAUABQAFAAUABwAHAAUABQAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABQAFAAcABwAHAAUABwAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAcABQAFAAUABQAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAAAAAABwAFAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAAAAAAAAAFAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAUABQAHAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAUABQAFAAUABQAHAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAcABwAFAAUABQAFAAcABwAFAAUABwAHAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAFAAcABwAFAAUABwAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAFAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAFAAUABQAAAAAABQAFAAAAAAAAAAAAAAAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAcABwAAAAAAAAAAAAAABwAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAcABwAFAAcABwAAAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAFAAUABQAAAAUABQAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABwAFAAUABQAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAUABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAHAAcABQAHAAUABQAAAAAAAAAAAAAAAAAFAAAABwAHAAcABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAHAAcABwAAAAAABwAHAAAAAAAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABwAHAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAFAAUABwAFAAcABwAFAAcABQAFAAcABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAHAAcABQAFAAUABQAAAAAABwAHAAcABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAHAAUABQAFAAUABQAFAAUABQAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABwAFAAcABwAFAAUABQAFAAUABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAcABwAFAAUABQAFAAcABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAUABQAFAAUABQAHAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAFAAUABQAFAAAAAAAFAAUABwAHAAcABwAFAAAAAAAAAAcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABwAHAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAcABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUAAAAHAAUABQAFAAUABQAFAAUABwAFAAUABwAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUAAAAAAAAABQAAAAUABQAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAcABwAHAAcAAAAFAAUAAAAHAAcABQAHAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAAAAUABQAFAAAAAAAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAFAAUABQAAAAAABQAFAAUABQAFAAUABQAAAAUABQAAAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFAAUABQAFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAFAAUABQAFAAUABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAFAAUABQAFAAUADgAOAA4ADgAOAA4ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAcABwAHAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAMAAwADAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkAAAAAAAAAAAAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAAAAAAAAAAAAsADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwACwAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAADgAOAA4AAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAAAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4AAAAOAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAAAAAAAAAAAA4AAAAOAAAAAAAAAAAADgAOAA4AAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAA4ADgAOAA4ADgAOAA4ADgAOAAAADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4ADgAOAAAAAAAAAAAAAAAAAAAAAAAAAAAADgAOAA4ADgAOAA4AAAAAAAAAAAAAAAAAAAAAAA4ADgAOAA4ADgAOAA4ADgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAOAA4ADgAOAA4ADgAAAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AAAAAAAAAAAA="),xr=Array.isArray(vr)?function(A){for(var e=A.length,t=[],r=0;rs.x||t.y>s.y;return s=t,0===e||A});return A.body.removeChild(e),t}(document);return Object.defineProperty(Xr,"SUPPORT_WORD_BREAKING",{value:A}),A},get SUPPORT_SVG_DRAWING(){var A=function(A){var e=new Image,t=A.createElement("canvas"),A=t.getContext("2d");if(!A)return!1;e.src="data:image/svg+xml,";try{A.drawImage(e,0,0),t.toDataURL()}catch(A){return!1}return!0}(document);return Object.defineProperty(Xr,"SUPPORT_SVG_DRAWING",{value:A}),A},get SUPPORT_FOREIGNOBJECT_DRAWING(){var A="function"==typeof Array.from&&"function"==typeof window.fetch?function(t){var A=t.createElement("canvas"),r=100;A.width=r,A.height=r;var B=A.getContext("2d");if(!B)return Promise.reject(!1);B.fillStyle="rgb(0, 255, 0)",B.fillRect(0,0,r,r);var e=new Image,n=A.toDataURL();e.src=n;e=Nr(r,r,0,0,e);return B.fillStyle="red",B.fillRect(0,0,r,r),Pr(e).then(function(A){B.drawImage(A,0,0);var e=B.getImageData(0,0,r,r).data;B.fillStyle="red",B.fillRect(0,0,r,r);A=t.createElement("div");return A.style.backgroundImage="url("+n+")",A.style.height="100px",Lr(e)?Pr(Nr(r,r,0,0,A)):Promise.reject(!1)}).then(function(A){return B.drawImage(A,0,0),Lr(B.getImageData(0,0,r,r).data)}).catch(function(){return!1})}(document):Promise.resolve(!1);return Object.defineProperty(Xr,"SUPPORT_FOREIGNOBJECT_DRAWING",{value:A}),A},get SUPPORT_CORS_IMAGES(){var A=void 0!==(new Image).crossOrigin;return Object.defineProperty(Xr,"SUPPORT_CORS_IMAGES",{value:A}),A},get SUPPORT_RESPONSE_TYPE(){var A="string"==typeof(new XMLHttpRequest).responseType;return Object.defineProperty(Xr,"SUPPORT_RESPONSE_TYPE",{value:A}),A},get SUPPORT_CORS_XHR(){var A="withCredentials"in new XMLHttpRequest;return Object.defineProperty(Xr,"SUPPORT_CORS_XHR",{value:A}),A},get SUPPORT_NATIVE_TEXT_SEGMENTATION(){var A=!("undefined"==typeof Intl||!Intl.Segmenter);return Object.defineProperty(Xr,"SUPPORT_NATIVE_TEXT_SEGMENTATION",{value:A}),A}},Jr=function(A,e){this.text=A,this.bounds=e},Yr=function(A,e){var t=e.ownerDocument;if(t){var r=t.createElement("html2canvaswrapper");r.appendChild(e.cloneNode(!0));t=e.parentNode;if(t){t.replaceChild(r,e);A=f(A,r);return r.firstChild&&t.replaceChild(r.firstChild,r),A}}return d.EMPTY},Wr=function(A,e,t){var r=A.ownerDocument;if(!r)throw new Error("Node has no owner document");r=r.createRange();return r.setStart(A,e),r.setEnd(A,e+t),r},Zr=function(A){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var e=new Intl.Segmenter(void 0,{granularity:"grapheme"});return Array.from(e.segment(A)).map(function(A){return A.segment})}return function(A){for(var e,t=mr(A),r=[];!(e=t.next()).done;)e.value&&r.push(e.value.slice());return r}(A)},_r=function(A,e){return 0!==e.letterSpacing?Zr(A):function(A,e){if(Xr.SUPPORT_NATIVE_TEXT_SEGMENTATION){var t=new Intl.Segmenter(void 0,{granularity:"word"});return Array.from(t.segment(A)).map(function(A){return A.segment})}return jr(A,e)}(A,e)},qr=[32,160,4961,65792,65793,4153,4241],jr=function(A,e){for(var t,r=wA(A,{lineBreak:e.lineBreak,wordBreak:"break-word"===e.overflowWrap?"break-word":e.wordBreak}),B=[];!(t=r.next()).done;)!function(){var A,e;t.value&&(A=t.value.slice(),A=Q(A),e="",A.forEach(function(A){-1===qr.indexOf(A)?e+=g(A):(e.length&&B.push(e),B.push(g(A)),e="")}),e.length&&B.push(e))}();return B},zr=function(A,e,t){var B,n,s,o,i;this.text=$r(e.data,t.textTransform),this.textBounds=(B=A,A=this.text,s=e,A=_r(A,n=t),o=[],i=0,A.forEach(function(A){var e,t,r;n.textDecorationLine.length||0e.height?new d(e.left+(e.width-e.height)/2,e.top,e.height,e.height):e.width"),Ln(this.referenceElement.ownerDocument,t,n),o.replaceChild(o.adoptNode(this.documentElement),o.documentElement),o.close(),A},fn.prototype.createElementClone=function(A){if(Cr(A,2),zB(A))return this.createCanvasClone(A);if(MB(A))return this.createVideoClone(A);if(SB(A))return this.createStyleClone(A);var e=A.cloneNode(!1);return $B(e)&&($B(A)&&A.currentSrc&&A.currentSrc!==A.src&&(e.src=A.currentSrc,e.srcset=""),"lazy"===e.loading&&(e.loading="eager")),TB(e)?this.createCustomElementClone(e):e},fn.prototype.createCustomElementClone=function(A){var e=document.createElement("html2canvascustomelement");return Kn(A.style,e),e},fn.prototype.createStyleClone=function(A){try{var e=A.sheet;if(e&&e.cssRules){var t=[].slice.call(e.cssRules,0).reduce(function(A,e){return e&&"string"==typeof e.cssText?A+e.cssText:A},""),r=A.cloneNode(!1);return r.textContent=t,r}}catch(A){if(this.context.logger.error("Unable to access cssRules property",A),"SecurityError"!==A.name)throw A}return A.cloneNode(!1)},fn.prototype.createCanvasClone=function(e){var A;if(this.options.inlineImages&&e.ownerDocument){var t=e.ownerDocument.createElement("img");try{return t.src=e.toDataURL(),t}catch(A){this.context.logger.info("Unable to inline canvas contents, canvas is tainted",e)}}t=e.cloneNode(!1);try{t.width=e.width,t.height=e.height;var r,B,n=e.getContext("2d"),s=t.getContext("2d");return s&&(!this.options.allowTaint&&n?s.putImageData(n.getImageData(0,0,e.width,e.height),0,0):(!(r=null!==(A=e.getContext("webgl2"))&&void 0!==A?A:e.getContext("webgl"))||!1===(null==(B=r.getContextAttributes())?void 0:B.preserveDrawingBuffer)&&this.context.logger.warn("Unable to clone WebGL context as it has preserveDrawingBuffer=false",e),s.drawImage(e,0,0))),t}catch(A){this.context.logger.info("Unable to clone canvas as it is tainted",e)}return t},fn.prototype.createVideoClone=function(e){var A=e.ownerDocument.createElement("canvas");A.width=e.offsetWidth,A.height=e.offsetHeight;var t=A.getContext("2d");try{return t&&(t.drawImage(e,0,0,A.width,A.height),this.options.allowTaint||t.getImageData(0,0,A.width,A.height)),A}catch(A){this.context.logger.info("Unable to clone video as it is tainted",e)}A=e.ownerDocument.createElement("canvas");return A.width=e.offsetWidth,A.height=e.offsetHeight,A},fn.prototype.appendChildNode=function(A,e,t){XB(e)&&("SCRIPT"===e.tagName||e.hasAttribute(hn)||"function"==typeof this.options.ignoreElements&&this.options.ignoreElements(e))||this.options.copyStyles&&XB(e)&&SB(e)||A.appendChild(this.cloneNode(e,t))},fn.prototype.cloneChildNodes=function(A,e,t){for(var r,B=this,n=(A.shadowRoot||A).firstChild;n;n=n.nextSibling)XB(n)&&rn(n)&&"function"==typeof n.assignedNodes?(r=n.assignedNodes()).length&&r.forEach(function(A){return B.appendChildNode(e,A,t)}):this.appendChildNode(e,n,t)},fn.prototype.cloneNode=function(A,e){if(PB(A))return document.createTextNode(A.data);if(!A.ownerDocument)return A.cloneNode(!1);var t=A.ownerDocument.defaultView;if(t&&XB(A)&&(JB(A)||YB(A))){var r=this.createElementClone(A);r.style.transitionProperty="none";var B=t.getComputedStyle(A),n=t.getComputedStyle(A,":before"),s=t.getComputedStyle(A,":after");this.referenceElement===A&&JB(r)&&(this.clonedReferenceElement=r),jB(r)&&Mn(r);t=this.counters.parse(new Ur(this.context,B)),n=this.resolvePseudoContent(A,r,n,gn.BEFORE);TB(A)&&(e=!0),MB(A)||this.cloneChildNodes(A,r,e),n&&r.insertBefore(n,r.firstChild);s=this.resolvePseudoContent(A,r,s,gn.AFTER);return s&&r.appendChild(s),this.counters.pop(t),(B&&(this.options.copyStyles||YB(A))&&!An(A)||e)&&Kn(B,r),0===A.scrollTop&&0===A.scrollLeft||this.scrolledElements.push([r,A.scrollLeft,A.scrollTop]),(en(A)||tn(A))&&(en(r)||tn(r))&&(r.value=A.value),r}return A.cloneNode(!1)},fn.prototype.resolvePseudoContent=function(o,A,e,t){var i=this;if(e){var r=e.content,Q=A.ownerDocument;if(Q&&r&&"none"!==r&&"-moz-alt-content"!==r&&"none"!==e.display){this.counters.parse(new Ur(this.context,e));var c=new wr(this.context,e),a=Q.createElement("html2canvaspseudoelement");Kn(e,a),c.content.forEach(function(A){if(0===A.type)a.appendChild(Q.createTextNode(A.value));else if(22===A.type){var e=Q.createElement("img");e.src=A.value,e.style.opacity="1",a.appendChild(e)}else if(18===A.type){var t,r,B,n,s;"attr"===A.name?(e=A.values.filter(_A)).length&&a.appendChild(Q.createTextNode(o.getAttribute(e[0].value)||"")):"counter"===A.name?(B=(r=A.values.filter($A))[0],r=r[1],B&&_A(B)&&(t=i.counters.getCounterValue(B.value),s=r&&_A(r)?pt.parse(i.context,r.value):3,a.appendChild(Q.createTextNode(Fn(t,s,!1))))):"counters"===A.name&&(B=(t=A.values.filter($A))[0],s=t[1],r=t[2],B&&_A(B)&&(B=i.counters.getCounterValues(B.value),n=r&&_A(r)?pt.parse(i.context,r.value):3,s=s&&0===s.type?s.value:"",s=B.map(function(A){return Fn(A,n,!1)}).join(s),a.appendChild(Q.createTextNode(s))))}else if(20===A.type)switch(A.value){case"open-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,i.quoteDepth++,!0)));break;case"close-quote":a.appendChild(Q.createTextNode(Xt(c.quotes,--i.quoteDepth,!1)));break;default:a.appendChild(Q.createTextNode(A.value))}}),a.className=Dn+" "+vn;t=t===gn.BEFORE?" "+Dn:" "+vn;return YB(A)?A.className.baseValue+=t:A.className+=t,a}}},fn.destroy=function(A){return!!A.parentNode&&(A.parentNode.removeChild(A),!0)},fn);function fn(A,e,t){if(this.context=A,this.options=t,this.scrolledElements=[],this.referenceElement=e,this.counters=new Bn,this.quoteDepth=0,!e.ownerDocument)throw new Error("Cloned element does not have an owner document");this.documentElement=this.cloneNode(e.ownerDocument.documentElement,!1)}(he=gn=gn||{})[he.BEFORE=0]="BEFORE",he[he.AFTER=1]="AFTER";function Hn(e){return new Promise(function(A){!e.complete&&e.src?(e.onload=A,e.onerror=A):A()})}var pn=function(A,e){var t=A.createElement("iframe");return t.className="html2canvas-container",t.style.visibility="hidden",t.style.position="fixed",t.style.left="-10000px",t.style.top="0px",t.style.border="0",t.width=e.width.toString(),t.height=e.height.toString(),t.scrolling="no",t.setAttribute(hn,"true"),A.body.appendChild(t),t},En=function(A){return Promise.all([].slice.call(A.images,0).map(Hn))},In=function(B){return new Promise(function(e,A){var t=B.contentWindow;if(!t)return A("No window assigned for iframe");var r=t.document;t.onload=B.onload=function(){t.onload=B.onload=null;var A=setInterval(function(){0"),e},Ln=function(A,e,t){A&&A.defaultView&&(e!==A.defaultView.pageXOffset||t!==A.defaultView.pageYOffset)&&A.defaultView.scrollTo(e,t)},bn=function(A){var e=A[0],t=A[1],A=A[2];e.scrollLeft=t,e.scrollTop=A},Dn="___html2canvas___pseudoelement_before",vn="___html2canvas___pseudoelement_after",xn='{\n content: "" !important;\n display: none !important;\n}',Mn=function(A){Sn(A,"."+Dn+":before"+xn+"\n ."+vn+":after"+xn)},Sn=function(A,e){var t=A.ownerDocument;t&&((t=t.createElement("style")).textContent=e,A.appendChild(t))},Tn=(Gn.getOrigin=function(A){var e=Gn._link;return e?(e.href=A,e.href=e.href,e.protocol+e.hostname+e.port):"about:blank"},Gn.isSameOrigin=function(A){return Gn.getOrigin(A)===Gn._origin},Gn.setContext=function(A){Gn._link=A.document.createElement("a"),Gn._origin=Gn.getOrigin(A.location.href)},Gn._origin="about:blank",Gn);function Gn(){}var On=(Vn.prototype.addImage=function(A){var e=Promise.resolve();return this.has(A)||(Yn(A)||Pn(A))&&(this._cache[A]=this.loadImage(A)).catch(function(){}),e},Vn.prototype.match=function(A){return this._cache[A]},Vn.prototype.loadImage=function(s){return a(this,void 0,void 0,function(){var e,r,t,B,n=this;return H(this,function(A){switch(A.label){case 0:return(e=Tn.isSameOrigin(s),r=!Xn(s)&&!0===this._options.useCORS&&Xr.SUPPORT_CORS_IMAGES&&!e,t=!Xn(s)&&!e&&!Yn(s)&&"string"==typeof this._options.proxy&&Xr.SUPPORT_CORS_XHR&&!r,e||!1!==this._options.allowTaint||Xn(s)||Yn(s)||t||r)?(B=s,t?[4,this.proxy(B)]:[3,2]):[2];case 1:B=A.sent(),A.label=2;case 2:return this.context.logger.debug("Added image "+s.substring(0,256)),[4,new Promise(function(A,e){var t=new Image;t.onload=function(){return A(t)},t.onerror=e,(Jn(B)||r)&&(t.crossOrigin="anonymous"),t.src=B,!0===t.complete&&setTimeout(function(){return A(t)},500),0t.width+C?0:Math.max(0,n-C),Math.max(0,s-l),As.TOP_RIGHT):new Zn(t.left+t.width-C,t.top+l),this.bottomRightPaddingBox=0t.width+F+A?0:n-F+A,s-(l+h),As.TOP_RIGHT):new Zn(t.left+t.width-(C+d),t.top+l+h),this.bottomRightContentBox=0A.element.container.styles.zIndex.order?(s=e,!1):0=A.element.container.styles.zIndex.order?(o=e+1,!1):0 重新加载」生效,无需重启 + +# ===== 系统路径 ===== +base-directory= +export-directory= + +# ===== 数据文件路径(相对于 HomeUtils 应用根目录)===== +project-paths-file=data/project_paths.txt +damaged-repos-file=data/damaged_repos.cache + +# ===== MyBatis 数据库连接(覆盖内置值)===== +mybatis.db.driver= +mybatis.db.url= +mybatis.db.username= +mybatis.db.password= + +# ===== MyBatis 代码生成包路径 ===== +mybatis.model.package= +mybatis.dao.package= +mybatis.sqlmapper.package= + +# ===== 字体大小(显示设置)===== +font.size.section-title=20 +font.size.field-label=18 +font.size.button=18 +font.size.body=18 +font.size.mono=18 diff --git a/src/main/resources/templates/mybatis-generator.properties b/src/main/resources/templates/mybatis-generator.properties new file mode 100644 index 0000000..e0932a8 --- /dev/null +++ b/src/main/resources/templates/mybatis-generator.properties @@ -0,0 +1,11 @@ +db_driver=com.mysql.jdbc.Driver +db_url=jdbc:mysql://localhost:3306/disburse?useUnicode=true&characterEncoding=utf8&useSSL=false +db_username=root +db_password=1Qaz@4772 + +model_package=model +model_target_project=./generator +sqlmapper_package=mapperxml +sqlmapper_target_project=./generator +dao_package=mapper +dao_target_project=./generator diff --git a/src/main/resources/templates/plugins.config b/src/main/resources/templates/plugins.config new file mode 100644 index 0000000..279ac27 --- /dev/null +++ b/src/main/resources/templates/plugins.config @@ -0,0 +1,5 @@ +# ====== HomeUtils 插件配置文件 ====== +# 插件顺序(按逗号分隔,顺序即菜单显示顺序) +# 插件状态(disabled 列表,不在列表中的即为启用) +plugins.order=wechat-ai +plugins.disabled= diff --git a/src/main/resources/templates/project_paths.txt b/src/main/resources/templates/project_paths.txt new file mode 100644 index 0000000..b9a3714 --- /dev/null +++ b/src/main/resources/templates/project_paths.txt @@ -0,0 +1,72 @@ +# 项目路径配置文件 +# 格式: 本地路径=Git仓库URL(每行一个项目) +# 示例: D:/workspace/my-project=https://user@code.example.com/org/repo.git +D:/bestpay-srdcloud/ft-compensatory-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-compensatory-core.git +D:/bestpay-srdcloud/ft-creative-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-creative-prod.git +D:/bestpay-srdcloud/ft-credit-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-credit-prod.git +D:/bestpay-srdcloud/ft-customer-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-customer-core.git +D:/bestpay-srdcloud/ft-file-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-file-core.git +D:/bestpay-srdcloud/ft-finflux-gw-adapter=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-finflux-gw-adapter.git +D:/bestpay-srdcloud/ft-lending-management-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-lending-management-core.git +D:/bestpay-srdcloud/ft-loancapital-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-loancapital-core.git +D:/bestpay-srdcloud/ft-loan-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-loan-core.git +D:/bestpay-srdcloud/ft-organmng-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-organmng-core.git +D:/bestpay-srdcloud/ft-orgsupport-weixin=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-orgsupport-weixin.git +D:/bestpay-srdcloud/ft-protocol-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-protocol-core.git +D:/bestpay-srdcloud/ft-repay-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-repay-core.git +D:/bestpay-srdcloud/ft-risk-control-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-risk-control-core.git +D:/bestpay-srdcloud/ft-settlement-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-settlement-core.git +D:/bestpay-srdcloud/ft-wechatstage-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-wechatstage-prod.git +D:/bestpay-srdcloud/cf-agent-admin=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-agent-admin.git +D:/bestpay-srdcloud/cf-batch-creditpayment=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-batch-creditpayment.git +D:/bestpay-srdcloud/cf-batch-creditpayment-plus=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-batch-creditpayment-plus.git +D:/bestpay-srdcloud/cf-blockchain-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-blockchain-prod.git +D:/bestpay-srdcloud/cf-boss=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-boss.git +D:/bestpay-srdcloud/cf-clrstl-fee-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-clrstl-fee-core.git +D:/bestpay-srdcloud/cf-clrstl-report-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-clrstl-report-core.git +D:/bestpay-srdcloud/cf-codeutils=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-codeutils.git +D:/bestpay-srdcloud/cf-collateral-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-collateral-core.git +D:/bestpay-srdcloud/cf-commodity-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-commodity-core.git +D:/bestpay-srdcloud/cf-commonservice=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-commonservice.git +D:/bestpay-srdcloud/cf-consumestage-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-consumestage-core.git +D:/bestpay-srdcloud/cf-consumestage-mall=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-consumestage-mall.git +D:/bestpay-srdcloud/cf-credit-bizfront=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credit-bizfront.git +D:/bestpay-srdcloud/cf-credit-bizfront-plus=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credit-bizfront-plus.git +D:/bestpay-srdcloud/cf-credit-front=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credit-front.git +D:/bestpay-srdcloud/cf-credit-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credit-prod.git +D:/bestpay-srdcloud/cf-credit-prod-manage=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credit-prod-manage.git +D:/bestpay-srdcloud/cf-creditstagescore=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-creditstagescore.git +D:/bestpay-srdcloud/cf-credituse-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-credituse-core.git +D:/bestpay-srdcloud/cf-ctcorp-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-ctcorp-core.git +D:/bestpay-srdcloud/cf-customer-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-customer-core.git +D:/bestpay-srdcloud/cf-file-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-file-core.git +D:/bestpay-srdcloud/cf-govent-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-govent-prod.git +D:/bestpay-srdcloud/cf-grant-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-grant-core.git +D:/bestpay-srdcloud/cf-info-collection=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-info-collection.git +D:/bestpay-srdcloud/cf-instalment-admin=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-instalment-admin.git +D:/bestpay-srdcloud/cf-instalment-batch=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-instalment-batch.git +D:/bestpay-srdcloud/cf-invoice-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-invoice-core.git +D:/bestpay-srdcloud/cf-lending-management-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-lending-management-core.git +D:/bestpay-srdcloud/cf-loancapital-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-loancapital-core.git +D:/bestpay-srdcloud/cf-marketing-manage=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-marketing-manage.git +D:/bestpay-srdcloud/cf-marketing-manage-plus=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-marketing-manage-plus.git +D:/bestpay-srdcloud/cf-onlinestage-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-onlinestage-prod.git +D:/bestpay-srdcloud/cf-organmng-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-organmng-core.git +D:/bestpay-srdcloud/cf-precredit-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-precredit-core.git +D:/bestpay-srdcloud/cf-productcfg-core-plus=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-productcfg-core-plus.git +D:/bestpay-srdcloud/cf-protocol-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-protocol-core.git +D:/bestpay-srdcloud/cf-risktool=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-risktool.git +D:/bestpay-srdcloud/cf-scheduler=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-scheduler.git +D:/bestpay-srdcloud/cf-search=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-search.git +D:/bestpay-srdcloud/cf-settlement-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-settlement-core.git +D:/bestpay-srdcloud/cf-thirdpartybiz-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-thirdpartybiz-prod.git +D:/bestpay-srdcloud/cf-trade-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-trade-core.git +D:/bestpay-srdcloud/cf-wechatstage-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/cf-wechatstage-prod.git +D:/bestpay-srdcloud/ft-approval-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-approval-core.git +D:/bestpay-srdcloud/ft-asset-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-asset-core.git +D:/bestpay-srdcloud/ft-blockchain-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-blockchain-prod.git +D:/bestpay-srdcloud/ft-collateral-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-collateral-core.git +D:/bestpay-srdcloud/ft-commodity-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-commodity-core.git +D:/bestpay-srdcloud/ft-commonservice=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-commonservice.git +D:/bestpay-srdcloud/ft-deposit-core=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-deposit-core +D:/bestpay-srdcloud/ft-orgsupport-prod=https://srd17321104074@code.srdcloud.cn/bestpay-cf/cf/ft-orgsupport-prod diff --git a/src/main/resources/投保-评审修改.sql b/src/main/resources/投保-评审修改.sql new file mode 100644 index 0000000..961b666 --- /dev/null +++ b/src/main/resources/投保-评审修改.sql @@ -0,0 +1,355 @@ +alter table risk_compensatory_contract_info + add column contract_type varchar(32) not null default 'GUARANTEE' comment 'ºÏԼÀàÐͣ»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.ContractTypeEnum'; + +alter table risk_compensatory_order_info + add column compensatory_contract_no varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + add column loan_status varchar(32) not null default '' comment '½è¾Ý״̬£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.common.enums.compensatory.LoanStatusEnum', + add column insurance_flag varchar(32) not null default '' comment 'Ͷ±£±êʶ£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.common.enums.compensatory.InsuranceFlagEnum', + add column area_code varchar(64) not null default '' comment 'ÇøÓò±àÂ룻ʡ±àÂ룻ALL=ËùÓУ¬ÉϺ£¡¢¹㶫...', + add column period_num int default null comment 'ÆÚÊýÊýÁ¿£»ÆÚÊý', + add column terminate_time datetime default null comment '½áÇåʱ¼ä'; + +create table `risk_insurance_contract_info` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬ÓÃÓڱêʶÐмǼÊý¾ÝÀ´Դ»·¾³¡£ÆäÖÐȫÁ¿ö¾٣ºÏßÏ£ºDEV(¿ª·¢»·¾³)£¬SIT(¼¯³ɲâÊԻ·¾³)£¬STB(Îȶ¨»·¾³)£»ÏßÉϣºPRE(Ԥ·¢»·¾³)£¬GRY(»ҶȻ·¾³)£¬PRD(Éú²ú»·¾³)', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `enabled_flag` tinyint(4) not null default '0' comment 'ÆôÓñêʶ£º0=δÆôÓã¬1=ÒÑÆôÓÃ', + `checked_at` datetime default null comment 'ÉóºËʱ¼ä', + `checked_by` varchar(128) not null default '' comment 'ÉóºËÈË', + `config_changed_flag` tinyint(4) not null default '0' comment 'ÅäÖñä¸ü±êʶ£º0=²»´æÔڴýÉóºˣ¬1=´æÔڴýÉóºË', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼ±àºÅ', + `insurer_org_ip_r_id` varchar(64) not null default '' comment '±£ÏÕÈ˻ú¹¹iprid', + `applicant_org_ip_r_id` varchar(64) not null default '' comment 'Ͷ±£È˻ú¹¹iprid', + `insurance_mode` varchar(32) not null default '' comment 'Ͷ±£ģʽ£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.InsuranceModeEnum', + `insurance_amount_basis` varchar(32) not null default '' comment '±£Ïսð¶î»ù׼£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.InsuranceAmountBasisEnum', + `insurance_cost_calculate_rule` varchar(128) not null default '' comment '±£·ѼÆËã¹æÔ򣻼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.InsuranceCostCalculateRuleEnum', + `insurance_cost_calculate_rounding_mode` varchar(32) not null default '' comment '±£·ѼÆËãÉáλģʽ£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.InsuranceCostCalculateRoundingModeEnum', + `insurance_flow_cfg_code` varchar(64) not null default '' comment 'Ͷ±£Á÷³ÌÅäÖñí±àÂë', + `pack_day` varchar(32) not null default '' comment '´ò°üÈգ»¸ñʽ£ºDD', + `pack_frequency` varchar(128) not null default '' comment '´ò°üƵÂʣ»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.PackFrequencyEnum', + `pack_basis` varchar(128) not null default '' comment '´ò°ü»ù׼£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.PackBasisEnum', + `pack_rule` varchar(32) not null default '' comment '´ò°ü¹æÔ򣻼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.contract.enums.PackRuleEnum', + PRIMARY KEY (`id`), + KEY `idx_compensatory_contract_no` (`compensatory_contract_no`), + KEY `idx_insurance_flow_cfg_code` (`insurance_flow_cfg_code`) +) default charset=utf8mb4 comment='Ͷ±£ºÏԼÐÅϢ±í'; + +create table `risk_insurance_flow_cfg` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬ÓÃÓڱêʶÐмǼÊý¾ÝÀ´Դ»·¾³¡£ÆäÖÐȫÁ¿ö¾٣ºÏßÏ£ºDEV(¿ª·¢»·¾³)£¬SIT(¼¯³ɲâÊԻ·¾³)£¬STB(Îȶ¨»·¾³)£»ÏßÉϣºPRE(Ԥ·¢»·¾³)£¬GRY(»ҶȻ·¾³)£¬PRD(Éú²ú»·¾³)', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `enabled_flag` tinyint(4) not null default '0' comment 'ÆôÓñêʶ£º0=δÆôÓã¬1=ÒÑÆôÓÃ', + `checked_by` varchar(128) not null default '' comment 'ÉóºËÈË', + `checked_at` datetime default null comment 'ÉóºËʱ¼ä', + `config_changed_flag` tinyint(4) not null default '0' comment 'ÅäÖñä¸ü±êʶ£»0=²»´æÔڴýÉóºˣ¬1=´æÔڴýÉóºË', + `flow_code` varchar(128) not null default '' comment 'Á÷³̱àºÅ', + `flow_name` varchar(128) not null default '' comment 'Á÷³ÌÃû³Æ', + `flow_desc` varchar(512) not null default '' comment 'Á÷³ÌÃèÊö', + PRIMARY KEY (`id`), + KEY `idx_flow_code` (`flow_code`) +) default charset=utf8mb4 comment='·çÏÕͶ±£Á÷³ÌÅäÖñí'; + +create table `risk_insurance_flow_node_cfg` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬ÓÃÓڱêʶÐмǼÊý¾ÝÀ´Դ»·¾³¡£ÆäÖÐȫÁ¿ö¾٣ºÏßÏ£ºDEV(¿ª·¢»·¾³)£¬SIT(¼¯³ɲâÊԻ·¾³)£¬STB(Îȶ¨»·¾³)£»ÏßÉϣºPRE(Ԥ·¢»·¾³)£¬GRY(»ҶȻ·¾³)£¬PRD(Éú²ú»·¾³)', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `enabled_flag` tinyint(4) not null default '0' comment 'ÆôÓñêʶ£º0=δÆôÓã¬1=ÒÑÆôÓÃ', + `checked_by` varchar(128) not null default '' comment 'ÉóºËÈË', + `checked_at` datetime default null comment 'ÉóºËʱ¼ä', + -- TODO ×ֶÎעÊͲ¹³äö¾٣» + `flow_code` varchar(64) not null default '' comment 'Á÷³̱àºÅ', + `node_code` varchar(64) not null default '' comment '½ڵã±àÂë', + `node_name` varchar(128) not null default '' comment '½ڵãÃû³Æ', + `node_interaction_way` varchar(32) not null default '' comment '½ڵ㽻»¥·½ʽ£º¼ûö¾٣ºcom.bestpay.ft.compensatory.core.common.enums.compensatory.NodeInteractionWayEnum', + `node_interaction_config_value` varchar(64) not null default '' comment '½ڵ㽻»¥ÅäÖÃֵ', + `next_node_code` varchar(64) not null default '' comment 'ϸö½ڵã±àºÅ', + PRIMARY KEY (`id`), + KEY `idx_flow_code` (`flow_code`), + KEY `idx_node_code` (`node_code`) +) default charset=utf8mb4 comment='·çÏÕͶ±£Á÷³̽ڵãÅäÖñí'; + +create table `risk_insurance_contract_insurance_cost_rate_cfg` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬ÓÃÓڱêʶÐмǼÊý¾ÝÀ´Դ»·¾³¡£ÆäÖÐȫÁ¿ö¾٣ºÏßÏ£ºDEV(¿ª·¢»·¾³)£¬SIT(¼¯³ɲâÊԻ·¾³)£¬STB(Îȶ¨»·¾³)£»ÏßÉϣºPRE(Ԥ·¢»·¾³)£¬GRY(»ҶȻ·¾³)£¬PRD(Éú²ú»·¾³)', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `enabled_flag` tinyint(4) not null default '0' comment 'ÆôÓñêʶ£º0=δÆôÓã¬1=ÒÑÆôÓÃ', + `checked_by` varchar(128) not null default '' comment 'ÉóºËÈË', + `checked_at` datetime default null comment 'ÉóºËʱ¼ä', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + `area_code` varchar(64) not null default '' comment 'ÇøÓò±àÂ룻ʡ±àÂ룻ALL=ËùÓУ¬ÉϺ£¡¢¹㶫...', + `area_name` varchar(64) not null default '' comment 'ÇøÓòÃû³ƣ»ʡÃû³Æ', + `period_num` int default null comment 'ÆÚÊýÊýÁ¿£ºALL=ËùÓУ¬12¡¢24...', + `insurance_cost_rate` varchar(32) not null default '' comment '±£·ѷÑÂʣ»Íò·Öλ', + PRIMARY KEY (`id`), + KEY `idx_compensatory_contract_no` (`compensatory_contract_no`) +) default charset=utf8mb4 comment='Ͷ±£ºÏԼ±£·ѷÑÂÊÅäÖñí'; + +------TODO ÆÀÉóID£º +create table `risk_insurance_evt` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `biz_no` varchar(64) not null default '' comment 'ÉÏÓÎҵÎ񵥺ţ»¸ù¾ÝʼþÀàÐͲ»һÑù£¬±ÈÈç´ò°üͶ±£µ¥£¬¼Ç¼´ú³¥ִÐÐÈÎÎñÅú´κÅ', + `req_time` datetime not null comment 'ÇëÇóʱ¼ä', + `req_id` varchar(64) not null default '' comment 'ÇëÇóÁ÷ˮºÅ', + `evt_type` varchar(32) not null default '' comment 'ʼþÀàÐͣ»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.insurance.enums.InsuranceEvtTypeEnum', + `node_code` varchar(64) not null default '' comment '½ڵã±àÂë', + `request_system` varchar(32) not null default '' comment 'ÇëÇóϵͳ', +-- TODO ²¹³䣺¼ûö¾Ù + `operator_type` varchar(32) not null default '' comment '²Ù×÷ÈËÀàÐͣº¼ûö¾٣º', + `operator_id` varchar(128) not null default '' comment '²Ù×÷ÈËid', + PRIMARY KEY (`id`), + KEY `idx_req_id` (`req_id`), + KEY `idx_req_time` (`req_time`), + KEY `idx_biz_no` (`biz_no`) +) default charset=utf8mb4 comment='Ͷ±£Á÷³Ìʼþ±í'; + +create table `risk_insurance_bill` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `biz_no` varchar(64) not null default '' comment 'ÉÏÓÎҵÎ񵥺ţ»´ò°üͶ±£µ¥Á÷³̽ڵãʼþÇëÇóÁ÷ˮºţ»Íâ¼ü¹ØÁªrisk_insurance_evt.req_id', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥¾ݺţ»Éú³ɹæÔòǰ׺£ºIBN_', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + `pack_date` datetime not null comment '´ò°üÈÕÆÚ', + `insurance_mode` varchar(32) not null default '' comment 'Ͷ±£ģʽ,com.bestpay.ft.compensatory.core.api.contract.enums.InsuranceModeEnum', + `insurance_amount_basis` varchar(32) not null default '' comment '±£Ïսð¶î»ù׼£¬com.bestpay.ft.compensatory.core.api.contract.enums.InsuranceAmountBasisEnum', + `insurance_cost_calculate_rule` varchar(64) not null default '' comment '±£·ѼÆËã¹æÔò,com.bestpay.ft.compensatory.core.api.contract.enums.InsuranceCostCalculateRuleEnum', + `insurance_cost_calculate_rounding_mode` varchar(32) not null default '' comment '±£·ѼÆËãÉáλģʽ£ºȡÕûģʽ,com.bestpay.ft.compensatory.core.api.contract.enums.InsuranceCostCalculateRoundingModeEnum', + `pack_rule` varchar(32) not null default '' comment '´ò°ü¹æÔò£¬com.bestpay.ft.compensatory.core.api.contract.enums.PackRuleEnum', + `total_insured_subject_count` int(11) not null default 0 comment 'Ͷ±£±êµÄ×ÜÊý£»×ܽè¾ÝÊý', + `total_insurance_amount` bigint(20) not null comment '×ܱ£Ïսð¶µ¥λ£º·Ö', + `insurance_status` varchar(32) not null default '' comment 'Ͷ±£״̬£»¼ûö¾٣ºcom.bestpay.ft.compensatory.core.api.insurance.enums.InsuranceStatusEnum', + `biz_trial_insurance_cost_amount` bigint(20) default null comment 'ҵÎñÊÔË㱣·ѽð¶µ¥λ£º·Ö', + `operation_trial_insurance_cost_amount` bigint(20) default null comment 'ÔËӪÊÔË㱣·ѽð¶µ¥λ£º·Ö', + `policy_no` varchar(64) not null default '' comment '±£µ¥ºÅ', + `current_flow_node_code` varchar(64) not null default '' comment 'µ±ǰÁ÷³̽ڵã±àÂë', + `version_no` int(11) not null default 0 comment '°汾ºţ¬ÀֹÛËø°汾ºţ¬update = ԭֵ+1£¬³õʼ»¯=0', + PRIMARY KEY (`id`), + KEY `idx_insurance_bill_no` (`insurance_bill_no`), + KEY `idx_compensatory_contract_no` (`compensatory_contract_no`), + KEY `idx_policy_no` (`policy_no`), + KEY `idx_biz_no` (`biz_no`) +) default charset=utf8mb4 comment='Ͷ±£µ¥¾ݱí'; + +create table `risk_insurance_cmd` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥ºţ»¹ØÁªrisk_insurance_bill.insurance_bill_no', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + `flow_node_code` varchar(64) not null default '' comment 'Á÷³̽ڵã±àÂë', + `cmd_no` varchar(64) not null default '' comment 'ָÁîºÅ', + `cmd_type` varchar(32) not null default '' comment 'ָÁîÀàÐÍ,com.bestpay.ft.compensatory.core.api.insurance.enums.InsuranceCmdTypeEnum', + `cmd_exec_start_time` datetime default null comment 'ָÁîִÐпªʼʱ¼ä', + `cmd_exec_end_time` datetime default null comment 'ָÁîִÐнáÊøʱ¼ä', + `cmd_status` varchar(32) not null default '' comment 'ָÁî״̬£¬com.bestpay.ft.compensatory.core.api.insurance.enums.InsuranceCmdStatusEnum', + `ext_biz_no` varchar(128) not null default '' comment 'ÍØչҵÎ񵥺ţ»ÍⲿҵÎ񵥺Å', + `fail_code` varchar(32) NOT NULL DEFAULT '' COMMENT 'ʧ°ܱàÂë', + `fail_message` varchar(128) NOT NULL DEFAULT '' COMMENT 'ʧ°ÜÏûϢ', + PRIMARY KEY (`id`), + KEY `idx_cmd_no` (`cmd_no`), + KEY `idx_compensatory_contract_no` (`compensatory_contract_no`), + KEY `idx_biz_no` (`biz_no`) +) default charset=utf8mb4 comment='Ͷ±£ִÐÐָÁî±í'; + +create table `risk_insurance_subject_bill` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `insurance_subject_bill_no` varchar(64) not null default '' comment 'Ͷ±£±êµĵ¥¾ݺÅ', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥¾ݺţ»Íâ¼ü', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + `fund_org_contract_no` varchar(64) not null default '' comment '×ʽð»ú¹¹ºÏԼºÅ', + `loan_no` varchar(64) not null default '' comment '½è¾ݺÅ', + `insurance_start_season` varchar(32) not null default '' comment 'Ͷ±£¿ªʼÕËÆڣ»¸ñʽ£ºyyyyMM', + `insurance_end_season` varchar(32) not null default '' comment 'Ͷ±£½áÊøÕËÆڣ»¸ñʽ£ºyyyyMM', + `insurance_amount` bigint(20) not null comment '±£Ïսð¶µ¥λ£º·Ö', + `insurance_status` varchar(32) not null default '' comment 'Ͷ±£״̬£ºcom.bestpay.ft.compensatory.core.api.insurance.enums.InsuranceSubjectInsuranceStatusEnum', + `insurance_status_desc` varchar(256) not null default '' comment 'Ͷ±£״̬ÃèÊö', + `version_no` int(11) not null default 0 comment '°汾ºţ¬ÀֹÛËø°汾ºţ¬update = ԭֵ+1£¬³õʼ»¯=0', + PRIMARY KEY (`id`), + KEY `idx_insurance_subject_bill_no` (`insurance_subject_bill_no`), + KEY `idx_insurance_bill_no` (`insurance_bill_no`), + KEY `idx_loan_no` (`loan_no`) +) default charset=utf8mb4 comment='Ͷ±£±êµĵ¥¾ݱí'; + +create table `risk_insurance_subject_insurance_cost_info` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `insurance_subject_bill_no` varchar(64) not null default '' comment 'Ͷ±£±êµĵ¥¾ݺţ»Íâ¼ü', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥¾ݺţ»ÈßÓà', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºţ»ÈßÓà', + `fund_org_contract_no` varchar(64) not null default '' comment '×ʽð»ú¹¹ºÏԼºţ»ÈßÓà', + `loan_no` varchar(64) not null default '' comment '½è¾ݺţ»ÈßÓà', + `insurance_cost_rate` varchar(64) not null default '' comment '±£·ѷÑÂʣ»ÈßÓà', + `biz_trial_insurance_cost_amount` bigint(20) default null comment 'ҵÎñÊÔË㱣·ѽð¶µ¥λ£»·Ö', + `operation_trial_insurance_cost_amount` bigint(20) default null comment 'ÔËӪÊÔË㱣·ѽð¶µ¥λ£º·Ö', + `insurance_company_insurance_cost_amount` bigint(20) default null comment '±£˾±£·ѽð¶µ¥λ£º·Ö', + PRIMARY KEY (`id`), + KEY `idx_insurance_subject_bill_no` (`insurance_subject_bill_no`), + KEY `idx_insurance_bill_no` (`insurance_bill_no`), + KEY `idx_compensatory_contract_no` (`compensatory_contract_no`), + KEY `idx_loan_no` (`loan_no`) +) default charset=utf8mb4 comment='Ͷ±£±êµı£·ÑÐÅϢ±í'; + +--- ÆÀÉóID£ºhkoz8gfwly +create table `risk_insurance_loan_notify_evt` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `req_time` datetime not null comment 'ÇëÇóʱ¼ä', + `req_id` varchar(64) not null default '' comment 'ÇëÇóÁ÷ˮºÅ', + `biz_no` varchar(64) not null default '' comment 'ÉÏÓÎҵÎ񵥺ţ»½è¾ݺÅ', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + PRIMARY KEY (`id`), + KEY `idx_req_id` (`req_id`), + KEY `idx_req_time` (`req_time`), + KEY `idx_biz_no` (`biz_no`) +) default charset=utf8mb4 comment='Ͷ±£½è¾Ý֪ͨʼþ±í'; + +create table `risk_insurance_policy_bill` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `tnt_id` varchar(64) not null default '' comment '×⻧id', + `product_line_code` varchar(64) not null default '' comment '²úƷÏ߱àÂë', + `biz_no` varchar(64) not null default '' comment 'ÉÏÓÎҵÎ񵥺ţ»¼È뱣µ¥Á÷³̽ڵãʼþÇëÇóÁ÷ˮºţ»¹ØÁªrisk_insurance_evt.req_id', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥¾ݺţ»Íâ¼ü¹ØÁªrisk_insurance_bill.insurance_bill_no', + `policy_no` varchar(64) not null default '' comment '±£µ¥ºÅ', + `compensatory_contract_no` varchar(64) not null default '' comment '´ú³¥ºÏԼºÅ', + `insurer_org_ip_r_id` varchar(64) not null default '' comment '±£ÏÕÈ˻ú¹¹iprid', + `applicant_org_ip_r_id` varchar(64) not null default '' comment 'Ͷ±£È˻ú¹¹iprid', + `total_insurance_amount` bigint(20) not null comment '×ܱ£Ïսð¶µ¥λ£º·֣»×ܹ²ÄÜÀíÅâ¶àÉÙǮ', + `total_insurance_cost_amount` bigint(20) not null comment '×ܱ£·ѽð¶µ¥λ£»·֣»×ܹ²Ҫ½»¶àÉÙǮ', + `total_paid_insurance_cost_amount` bigint(20) default null comment '×ÜÒÑ֧¸¶±£·ѽð¶µ¥λ£º·Ö', + `total_pending_payment_insurance_cost_amount` bigint(20) default null comment '×ܴý֧¸¶±£·ѽð¶µ¥λ£º·Ö', + `effective_start_date` date default null comment 'ÉúЧ¿ªʼÈÕÆÚ', + `effective_end_date` date default null comment 'ÉúЧ½áÊøÈÕÆÚ', + `version_no` int(11) not null default 0 comment '°汾ºţ»ÀֹÛËø°汾ºţ¬update = ԭֵ+1£¬³õʼ»¯=0', + PRIMARY KEY (`id`), + KEY `idx_policy_no` (`policy_no`), + KEY `idx_insurance_bill_no` (`insurance_bill_no`), + KEY `idx_biz_no` (`biz_no`) +) default charset=utf8mb4 comment='±£µ¥µ¥¾ݱí'; + +create table `risk_insurance_file_info` +( + `id` bigint(20) unsigned not null auto_increment comment 'Ö÷¼üid', + `created_at` datetime not null comment '´´½¨ʱ¼ä', + `created_by` varchar(128) not null default 'sys' comment '´´½¨ÈË', + `updated_at` datetime default null comment '¸üÐÂʱ¼ä', + `updated_by` varchar(128) not null default '' comment '¸üÐÂÈË', + `archived_at` datetime default null comment '¹鵵ʱ¼ä', + `archived_by` varchar(128) not null default '' comment '¹鵵ÈË', + `archived_flag` tinyint(4) not null default '0' comment '¹鵵±êʶ£º0=δ¹鵵£¬1=Òѹ鵵', + `env` varchar(32) not null default '' comment '»·¾³±êʶ£¬DEV/SIT/STB/PRE/GRY/PRD', + `shadow` tinyint(4) not null default '0' comment 'Ӱ×ӱêʶλ (1 Ӱ×ÓÊý¾Ý 0 ·ÇӰ×ÓÊý¾Ý)', + `insurance_bill_no` varchar(64) not null default '' comment 'Ͷ±£µ¥¾ݺţ»Íâ¼ü¹ØÁª£ºrisk_insurance_bill', + `flow_node_code` varchar(64) not null default '' comment 'Á÷³̽ڵã±àÂë', + `file_type` varchar(32) not null default '' comment 'ÎļþÀàÐÍ', + `file_id` varchar(128) not null default '' comment 'ÎļþID', + PRIMARY KEY (`id`), + KEY `idx_insurance_bill_no` (`insurance_bill_no`) +) default charset=utf8mb4 comment='Ͷ±£µ¥ÎļþÐÅϢ±í'; \ No newline at end of file diff --git a/src/test/java/com/example/homeutils/system/config/HomeUtilsPropertiesPathTest.java b/src/test/java/com/example/homeutils/system/config/HomeUtilsPropertiesPathTest.java new file mode 100644 index 0000000..286dd86 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/config/HomeUtilsPropertiesPathTest.java @@ -0,0 +1,54 @@ +package com.example.homeutils.system.config; + +import com.example.homeutils.system.support.ApplicationPaths; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HomeUtilsPropertiesPathTest { + + @TempDir + Path tempDirectory; + + @Test + void relativeRuntimeFilesUseApplicationHome() { + HomeUtilsProperties properties = new HomeUtilsProperties(ApplicationPaths.of(tempDirectory)); + + assertEquals(tempDirectory.resolve(Path.of("data", "project_paths.txt")), + properties.getProjectPathsFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "damaged_repos.cache")), + properties.getDamagedReposFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "datasources.json")), + properties.getDatasourcesFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "homeutils.config")), + properties.getHomeutilsConfigFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "mybatis-generator.properties")), + properties.getMybatisGeneratorFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "plugins.config")), + properties.getPluginsConfigFilePath()); + assertEquals(tempDirectory.resolve(Path.of("data", "api_base_classes.json")), + properties.getApiBaseClassesFilePath()); + } + + @Test + void absoluteConfiguredFileIsPreserved() { + HomeUtilsProperties properties = new HomeUtilsProperties(ApplicationPaths.of(tempDirectory)); + Path absolute = tempDirectory.resolve("外部 配置.json").toAbsolutePath().normalize(); + properties.setDatasourcesFile(absolute.toString()); + + assertEquals(absolute, properties.getDatasourcesFilePath()); + } + + @Test + void snapshotDefaultsUsesApplicationHomeForBaseAndExportDirectories() { + HomeUtilsProperties properties = new HomeUtilsProperties(ApplicationPaths.of(tempDirectory)); + + properties.snapshotDefaults(); + + assertEquals(tempDirectory.toString(), properties.getBaseDirectory()); + assertEquals(tempDirectory.resolve("export"), properties.getExportBasePath()); + } +} diff --git a/src/test/java/com/example/homeutils/system/plugin/DefaultPluginConfigurationTest.java b/src/test/java/com/example/homeutils/system/plugin/DefaultPluginConfigurationTest.java new file mode 100644 index 0000000..788cf4e --- /dev/null +++ b/src/test/java/com/example/homeutils/system/plugin/DefaultPluginConfigurationTest.java @@ -0,0 +1,48 @@ +package com.example.homeutils.system.plugin; + +import com.example.homeutils.system.config.HomeUtilsProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DefaultPluginConfigurationTest { + + @TempDir + Path tempDir; + + @Test + void existingConfigurationAutomaticallyEnablesNewPluginsUnlessExplicitlyDisabled() throws Exception { + Path configFile = tempDir.resolve("plugins.config"); + Files.writeString(configFile, """ + plugins.order=wechat-ai + plugins.disabled= + """); + + HomeUtilsProperties properties = new HomeUtilsProperties(); + properties.setPluginsConfigFile(configFile.toString()); + List plugins = List.of( + plugin("wechat-ai"), + plugin("future-plugin")); + + PluginManager manager = new PluginManager(plugins, properties); + manager.init(); + + assertEquals(2, manager.getEnabledPlugins().size()); + assertEquals( + List.of("wechat-ai", "future-plugin"), + manager.getEnabledPlugins().stream().map(Plugin::getId).toList()); + } + + private Plugin plugin(String id) { + PluginDescriptor descriptor = PluginDescriptor.builder() + .id(id) + .name(id) + .build(); + return () -> descriptor; + } +} diff --git a/src/test/java/com/example/homeutils/system/support/ApplicationPathsTest.java b/src/test/java/com/example/homeutils/system/support/ApplicationPathsTest.java new file mode 100644 index 0000000..2890430 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/support/ApplicationPathsTest.java @@ -0,0 +1,64 @@ +package com.example.homeutils.system.support; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ApplicationPathsTest { + + @Test + void systemPropertyWinsOverEnvironmentAndWorkingDirectory() { + ApplicationPaths paths = ApplicationPaths.resolve( + " build/系统 属性 ", "build/环境变量", "build/工作目录"); + + assertEquals(Path.of("build", "系统 属性").toAbsolutePath().normalize(), paths.home()); + } + + @Test + void environmentWinsWhenSystemPropertyIsBlank() { + ApplicationPaths paths = ApplicationPaths.resolve( + " ", "build/环境 变量", "build/工作目录"); + + assertEquals(Path.of("build", "环境 变量").toAbsolutePath().normalize(), paths.home()); + } + + @Test + void blankOverridesFallBackToWorkingDirectory() { + ApplicationPaths paths = ApplicationPaths.resolve(" ", "", "build/工作 目录"); + + assertEquals(Path.of("build", "工作 目录").toAbsolutePath().normalize(), paths.home()); + } + + @Test + void missingAllSourcesIsRejected() { + assertThrows(IllegalStateException.class, + () -> ApplicationPaths.resolve(null, " ", null)); + } + + @Test + void configuredRelativePathUsesApplicationHome() { + ApplicationPaths paths = ApplicationPaths.of(Path.of("build", "运行 根目录")); + + assertEquals(paths.home().resolve(Path.of("data", "plugins.config")), + paths.resolveConfigured("data/plugins.config")); + } + + @Test + void configuredAbsolutePathIsPreserved() { + ApplicationPaths paths = ApplicationPaths.of(Path.of("build", "运行 根目录")); + Path absolute = Path.of(System.getProperty("java.io.tmpdir"), "绝对 配置").toAbsolutePath().normalize(); + + assertEquals(absolute, paths.resolveConfigured(absolute.toString())); + } + + @Test + void fallbackSegmentsAreResolvedWithoutEmbeddedSeparators() { + ApplicationPaths paths = ApplicationPaths.of(Path.of("build", "运行 根目录")); + + assertEquals(paths.home().resolve(Path.of("data", "api_base_classes.json")), + paths.resolveConfigured(null, "data", "api_base_classes.json")); + } +} diff --git a/src/test/java/com/example/homeutils/system/support/BuildPortabilityTest.java b/src/test/java/com/example/homeutils/system/support/BuildPortabilityTest.java new file mode 100644 index 0000000..5b373b4 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/support/BuildPortabilityTest.java @@ -0,0 +1,110 @@ +package com.example.homeutils.system.support; + +import org.junit.jupiter.api.Test; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilderFactory; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BuildPortabilityTest { + + private final Path projectRoot = Path.of("").toAbsolutePath().normalize(); + + @Test + void compilerUsesTheMavenJdkWithoutAnExternalExecutable() throws Exception { + Document pom = parsePom(); + Element compiler = pluginUnder(directChild(element(pom, "project"), "build"), + "maven-compiler-plugin"); + + assertEquals("17", childText(directChild(compiler, "configuration"), "release")); + assertFalse(hasDirectChild(directChild(compiler, "configuration"), "executable")); + assertFalse(hasDirectChild(directChild(compiler, "configuration"), "fork")); + } + + @Test + void launch4jRequiresAnExplicitWindowsPackageProfile() throws Exception { + Document pom = parsePom(); + Element project = element(pom, "project"); + assertFalse(hasPlugin(directChild(project, "build"), "launch4j-maven-plugin")); + + Element windowsProfile = profile(project, "windows-package"); + Element property = directChild(directChild(windowsProfile, "activation"), "property"); + assertEquals("windows.package", childText(property, "name")); + assertTrue(hasPlugin(directChild(windowsProfile, "build"), "launch4j-maven-plugin")); + } + + @Test + void portableShellLauncherIsShippedByTheDefaultBuild() throws Exception { + assertTrue(Files.isRegularFile(projectRoot.resolve(Path.of("scripts", "start.sh")))); + Document pom = parsePom(); + Element resourcesPlugin = pluginUnder( + directChild(element(parsePom(), "project"), "build"), "maven-resources-plugin"); + assertTrue(resourcesPlugin.getTextContent().contains("start.sh")); + assertFalse(resourcesPlugin.getTextContent().contains("start.bat")); + } + + private Document parsePom() throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(false); + return factory.newDocumentBuilder().parse(projectRoot.resolve("pom.xml").toFile()); + } + + private static Element element(Document document, String name) { + return (Element) document.getElementsByTagName(name).item(0); + } + + private static Element directChild(Element parent, String name) { + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element child && name.equals(child.getTagName())) return child; + } + throw new AssertionError("Missing element " + name + " under " + parent.getTagName()); + } + + private static boolean hasDirectChild(Element parent, String name) { + for (Node node = parent.getFirstChild(); node != null; node = node.getNextSibling()) { + if (node instanceof Element child && name.equals(child.getTagName())) return true; + } + return false; + } + + private static String childText(Element parent, String name) { + return directChild(parent, name).getTextContent().trim(); + } + + private static Element pluginUnder(Element build, String artifactId) { + Element plugins = directChild(build, "plugins"); + NodeList nodes = plugins.getElementsByTagName("plugin"); + for (int i = 0; i < nodes.getLength(); i++) { + Element plugin = (Element) nodes.item(i); + if (artifactId.equals(childText(plugin, "artifactId"))) return plugin; + } + throw new AssertionError("Missing plugin " + artifactId); + } + + private static boolean hasPlugin(Element build, String artifactId) { + try { + pluginUnder(build, artifactId); + return true; + } catch (AssertionError ignored) { + return false; + } + } + + private static Element profile(Element project, String id) { + Element profiles = directChild(project, "profiles"); + NodeList nodes = profiles.getElementsByTagName("profile"); + for (int i = 0; i < nodes.getLength(); i++) { + Element profile = (Element) nodes.item(i); + if (id.equals(childText(profile, "id"))) return profile; + } + throw new AssertionError("Missing profile " + id); + } +} diff --git a/src/test/java/com/example/homeutils/system/support/FileOperationHelperPathTest.java b/src/test/java/com/example/homeutils/system/support/FileOperationHelperPathTest.java new file mode 100644 index 0000000..12c08d9 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/support/FileOperationHelperPathTest.java @@ -0,0 +1,31 @@ +package com.example.homeutils.system.support; + +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FileOperationHelperPathTest { + + @Test + void emptyBaseDirectoryProducesRelativeFileName() { + String generated = new FileOperationHelper().generateFileName("", "报告", "html"); + Path path = Path.of(generated); + + assertFalse(path.isAbsolute()); + assertNull(path.getRoot()); + assertTrue(path.getFileName().toString().matches("报告_\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2}\\.html")); + } + + @Test + void baseDirectoryWithSpacesAndChineseIsPreserved() { + Path base = Path.of("build", "输出 空间"); + Path generated = Path.of(new FileOperationHelper() + .generateFileName(base.toString(), "报告", "html")); + + assertTrue(generated.startsWith(base)); + } +} diff --git a/src/test/java/com/example/homeutils/system/support/HistoryPersistencePathTest.java b/src/test/java/com/example/homeutils/system/support/HistoryPersistencePathTest.java new file mode 100644 index 0000000..9a80e9d --- /dev/null +++ b/src/test/java/com/example/homeutils/system/support/HistoryPersistencePathTest.java @@ -0,0 +1,27 @@ +package com.example.homeutils.system.support; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class HistoryPersistencePathTest { + + @TempDir + Path tempDirectory; + + @Test + void historyIsSavedUnderApplicationHomeWithUnicodeAndSpaces() { + HistoryPersistence persistence = new HistoryPersistence( + "扫描 历史.txt", ApplicationPaths.of(tempDirectory)); + + persistence.save(List.of("一条历史")); + + assertEquals(tempDirectory.resolve(Path.of("data", "扫描 历史.txt")).toString(), + persistence.getFilePath()); + assertEquals(List.of("一条历史"), persistence.load()); + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/DesignTokensTest.java b/src/test/java/com/example/homeutils/system/ui/DesignTokensTest.java new file mode 100644 index 0000000..997f3ec --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/DesignTokensTest.java @@ -0,0 +1,31 @@ +package com.example.homeutils.system.ui; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DesignTokensTest { + + @Test + void exposesApprovedDimensionsAndSelectsFirstAvailableFont() { + assertEquals(20, DesignTokens.FONT_PAGE_TITLE); + assertEquals(15, DesignTokens.FONT_SECTION_TITLE); + assertEquals(14, DesignTokens.FONT_BODY); + assertEquals(13, DesignTokens.FONT_SECONDARY); + assertEquals(40, DesignTokens.ROW_HEIGHT); + assertEquals(36, DesignTokens.CONTROL_HEIGHT); + assertEquals(214, DesignTokens.NAV_EXPANDED_WIDTH); + assertEquals(56, DesignTokens.NAV_COLLAPSED_WIDTH); + assertEquals(48, DesignTokens.HEADER_HEIGHT); + assertEquals("Segoe UI", DesignTokens.firstAvailable( + List.of("Missing Font", "Segoe UI"), List.of("Segoe UI"))); + } + + @Test + void fallsBackToSystemWhenNoPreferredFontIsInstalled() { + assertEquals("System", DesignTokens.firstAvailable( + List.of("JetBrains Mono", "Cascadia Mono", "Consolas"), List.of())); + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/EmptyStateContentTest.java b/src/test/java/com/example/homeutils/system/ui/EmptyStateContentTest.java new file mode 100644 index 0000000..8dc4f00 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/EmptyStateContentTest.java @@ -0,0 +1,25 @@ +package com.example.homeutils.system.ui; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class EmptyStateContentTest { + + @Test + void trimsDisplayText() { + EmptyStatePane.Content content = + new EmptyStatePane.Content(" 暂无结果 ", " 请先执行任务 ", ""); + + assertEquals("暂无结果", content.title()); + assertEquals("请先执行任务", content.description()); + assertEquals("", content.actionLabel()); + } + + @Test + void rejectsBlankTitle() { + assertThrows(IllegalArgumentException.class, + () -> new EmptyStatePane.Content(" ", "description", "")); + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/FxTestSupport.java b/src/test/java/com/example/homeutils/system/ui/FxTestSupport.java new file mode 100644 index 0000000..5aa2297 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/FxTestSupport.java @@ -0,0 +1,44 @@ +package com.example.homeutils.system.ui; + +import javafx.application.Platform; + +import java.util.concurrent.Callable; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; + +public final class FxTestSupport { + static { + try { + CountDownLatch started = new CountDownLatch(1); + Platform.startup(started::countDown); + started.await(); + } catch (IllegalStateException alreadyStarted) { + // JavaFX toolkit is already running in this test JVM. + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + Platform.setImplicitExit(false); + } + + public static T fx(Callable action) throws Exception { + FutureTask task = new FutureTask<>(action); + Platform.runLater(task); + return task.get(); + } + + public static void fx(ThrowingRunnable action) throws Exception { + fx(() -> { + action.run(); + return null; + }); + } + + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Exception; + } + + private FxTestSupport() { + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/FxTestSupportTest.java b/src/test/java/com/example/homeutils/system/ui/FxTestSupportTest.java new file mode 100644 index 0000000..2cb3825 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/FxTestSupportTest.java @@ -0,0 +1,17 @@ +package com.example.homeutils.system.ui; + +import javafx.application.Platform; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class FxTestSupportTest { + + @Test + void keepsToolkitAliveAcrossSequentialJavaFxTestClasses() throws Exception { + FxTestSupport.fx(() -> { }); + + assertFalse(Platform.isImplicitExit(), + "closing the last test Stage must not terminate the shared JavaFX toolkit"); + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/LightThemeCssRuntimeTest.java b/src/test/java/com/example/homeutils/system/ui/LightThemeCssRuntimeTest.java new file mode 100644 index 0000000..a9ba7cf --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/LightThemeCssRuntimeTest.java @@ -0,0 +1,353 @@ +package com.example.homeutils.system.ui; + +import com.example.homeutils.system.ui.async.AsyncOperationController; +import com.example.homeutils.system.ui.async.LoadingButtonBinder; +import com.example.homeutils.system.ui.icon.AppIcon; +import com.example.homeutils.system.ui.icon.IconFactory; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.css.CssParser; +import javafx.css.CssMetaData; +import javafx.css.PseudoClass; +import javafx.css.Styleable; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.TreeCell; +import javafx.scene.control.TreeItem; +import javafx.scene.control.TreeView; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static com.example.homeutils.system.ui.FxTestSupport.fx; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LightThemeCssRuntimeTest { + + @TempDir + Path tempDir; + + private final AtomicInteger stylesheetSequence = new AtomicInteger(); + + @Test + void realStylesheetAddsNoJavaFxParserErrors() throws Exception { + fx(() -> { + ParserRun parserRun = observeParserErrors(this::createFixture); + + assertNoParserErrors(parserRun); + assertEquals(20, parserRun.value().pageTitle().getFont().getSize(), + "A computed style proves that the unique stylesheet URL was parsed"); + }); + } + + @Test + void semanticClassesComputeApprovedTypographyAndDimensions() throws Exception { + fx(() -> { + ParserRun parserRun = observeParserErrors(this::createFixture); + Fixture fixture = parserRun.value(); + + assertNoParserErrors(parserRun); + + assertEquals(20, fixture.pageTitle().getFont().getSize()); + assertEquals(15, fixture.sectionTitle().getFont().getSize()); + assertEquals(14, fixture.bodyText().getFont().getSize()); + assertEquals(13, fixture.secondaryText().getFont().getSize()); + + assertEquals(13, fixture.logArea().getFont().getSize()); + assertEquals(40, fixture.table().getFixedCellSize()); + assertEquals(40, fixture.tree().getFixedCellSize()); + assertEquals(13, firstTableCell(fixture.table()).getFont().getSize()); + assertEquals(13, firstTreeCell(fixture.tree()).getFont().getSize()); + assertEquals(13, firstListCell(fixture.compactList()).getFont().getSize()); + assertEquals(14, assertInstanceOf( + Label.class, fixture.tabPane().lookup(".tab-label")).getFont().getSize()); + + assertEquals(36, fixture.defaultButton().getMinHeight()); + assertEquals(36, fixture.defaultButton().getPrefHeight()); + assertEquals(36, fixture.textField().getMinHeight()); + assertEquals(36, fixture.comboBox().getPrefHeight()); + assertEquals(48, fixture.header().getMinHeight()); + assertEquals(48, fixture.header().getPrefHeight()); + assertEquals(48, fixture.header().getMaxHeight()); + }); + } + + @Test + void disabledRunningPrimaryButtonKeepsPrimaryContrastAndWhiteSpinner() throws Exception { + fx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, Runnable::run); + Button button = new Button( + "开始生成", IconFactory.createButtonIcon(AppIcon.EXPORT, "开始生成")); + button.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + LoadingButtonBinder.bind(button, controller, "开始生成"); + + VBox root = new VBox(button); + Scene scene = new Scene(root, 320, 120); + scene.getStylesheets().add(copyRealStylesheetToUniqueUrl()); + root.applyCss(); + root.layout(); + + controller.start("正在生成预览", context -> null, ignored -> { }); + root.applyCss(); + root.layout(); + + assertTrue(button.isDisabled(), "running buttons must remain non-interactive"); + assertTrue(button.getPseudoClassStates().contains( + PseudoClass.getPseudoClass("running"))); + assertEquals(Color.web("#2563eb"), + button.getBackground().getFills().get(0).getFill()); + assertEquals(Color.WHITE, button.getTextFill()); + assertEquals(1, button.getOpacity(), 0.001, + "AtlantaFX disabled opacity must not wash out a running primary action"); + + ProgressIndicator spinner = assertInstanceOf( + ProgressIndicator.class, button.getGraphic()); + assertEquals(14, spinner.getPrefWidth()); + assertEquals(14, spinner.getPrefHeight()); + assertEquals(Color.WHITE, computedCssValue(spinner, "-fx-progress-color")); + assertTrue(pendingOperation.get() != null, + "the no-op executor must keep the fixture in RUNNING state"); + }); + } + + @Test + void repeatedUniqueLoadsPreserveAndRestoreParserErrorState() throws Exception { + fx(() -> { + ObservableList globalErrors = CssParser.errorsProperty(); + List errorsBefore = List.copyOf(globalErrors); + CssParser.ParseError preExistingError = new CssParser.ParseError("pre-existing test sentinel"); + globalErrors.add(preExistingError); + + try { + ParserRun firstLoad = observeParserErrors(this::createFixture); + + CssParser.ParseError capturedError = new CssParser.ParseError("captured test sentinel"); + ParserRun isolatedError = observeParserErrors(() -> { + globalErrors.add(capturedError); + return null; + }); + + ParserRun secondLoad = observeParserErrors(this::createFixture); + + assertNoParserErrors(firstLoad); + assertEquals(List.of(capturedError), isolatedError.errors()); + assertNoParserErrors(secondLoad); + assertNotEquals(firstLoad.value().stylesheetUrl(), secondLoad.value().stylesheetUrl(), + "Each run must use a unique URL so JavaFX cannot reuse a cached parse"); + assertEquals(20, firstLoad.value().pageTitle().getFont().getSize()); + assertEquals(20, secondLoad.value().pageTitle().getFont().getSize()); + + List expectedErrors = new ArrayList<>(errorsBefore); + expectedErrors.add(preExistingError); + assertEquals(expectedErrors, List.copyOf(globalErrors), + "The observer must preserve parser errors that predate a run"); + } finally { + removeByIdentity(globalErrors, List.of(preExistingError)); + assertEquals(errorsBefore, List.copyOf(globalErrors), + "The test must restore the parser's global error state"); + } + }); + } + + private ParserRun observeParserErrors(ThrowingSupplier action) throws Exception { + ObservableList globalErrors = CssParser.errorsProperty(); + List errorsBefore = List.copyOf(globalErrors); + List capturedErrors = new ArrayList<>(); + ListChangeListener listener = change -> { + while (change.next()) { + if (change.wasAdded()) { + capturedErrors.addAll(change.getAddedSubList()); + } + } + }; + + globalErrors.addListener(listener); + try { + return new ParserRun<>(action.get(), List.copyOf(capturedErrors)); + } finally { + globalErrors.removeListener(listener); + removeByIdentity(globalErrors, capturedErrors); + assertEquals(errorsBefore, List.copyOf(globalErrors), + "A stylesheet run must restore pre-existing JavaFX parser errors"); + } + } + + private static void removeByIdentity( + ObservableList globalErrors, + List errorsToRemove) { + for (int capturedIndex = errorsToRemove.size() - 1; capturedIndex >= 0; capturedIndex--) { + CssParser.ParseError capturedError = errorsToRemove.get(capturedIndex); + for (int globalIndex = globalErrors.size() - 1; globalIndex >= 0; globalIndex--) { + if (globalErrors.get(globalIndex) == capturedError) { + globalErrors.remove(globalIndex); + break; + } + } + } + } + + private static void assertNoParserErrors(ParserRun parserRun) { + assertTrue(parserRun.errors().isEmpty(), + () -> "JavaFX CSS parser errors: " + parserRun.errors()); + } + + private Fixture createFixture() throws IOException { + String stylesheetUrl = copyRealStylesheetToUniqueUrl(); + + Label pageTitle = labelWithStyle(Theme.CSS_PAGE_TITLE); + Label sectionTitle = labelWithStyle(Theme.CSS_SECTION_TITLE); + Label bodyText = labelWithStyle(Theme.CSS_BODY_TEXT); + Label secondaryText = labelWithStyle(Theme.CSS_SECONDARY_TEXT); + + TextArea logArea = new TextArea("log"); + logArea.getStyleClass().add(Theme.CSS_LOG_AREA); + + TableView table = new TableView<>(); + table.getStyleClass().add(Theme.CSS_TABLE); + TableColumn column = new TableColumn<>("Value"); + column.setCellValueFactory(data -> new SimpleStringProperty(data.getValue())); + table.getColumns().add(column); + table.getItems().add("table row"); + table.setPrefHeight(120); + + TreeItem treeRoot = new TreeItem<>("tree row"); + TreeView tree = new TreeView<>(treeRoot); + tree.getStyleClass().add(Theme.CSS_PROJECT_TREE); + tree.setPrefHeight(120); + + ListView compactList = new ListView<>(); + compactList.getStyleClass().add("compact-list"); + compactList.getItems().add("compact row"); + compactList.setPrefHeight(80); + + TabPane tabPane = new TabPane(new Tab("API tab", new Label("content"))); + tabPane.getStyleClass().add("apidoc-tab-pane"); + + Button defaultButton = new Button("Default"); + defaultButton.getStyleClass().add(Theme.CSS_CONTROL_DEFAULT); + TextField textField = new TextField("text"); + textField.getStyleClass().add(Theme.CSS_TEXT_FIELD); + ComboBox comboBox = new ComboBox<>(); + comboBox.getStyleClass().add(Theme.CSS_COMBO); + Region header = new Region(); + header.getStyleClass().add(Theme.CSS_HEADER_BAR); + + VBox root = new VBox(pageTitle, sectionTitle, bodyText, secondaryText, + logArea, table, tree, compactList, tabPane, + defaultButton, textField, comboBox, header); + Scene scene = new Scene(root, 900, 1000); + scene.getStylesheets().add(stylesheetUrl); + root.applyCss(); + root.layout(); + + return new Fixture(pageTitle, sectionTitle, bodyText, secondaryText, + logArea, table, tree, compactList, tabPane, + defaultButton, textField, comboBox, header, stylesheetUrl); + } + + private String copyRealStylesheetToUniqueUrl() throws IOException { + Path stylesheet = tempDir.resolve( + "light-theme-runtime-" + stylesheetSequence.incrementAndGet() + ".css"); + try (InputStream input = LightThemeCssRuntimeTest.class.getClassLoader() + .getResourceAsStream("css/light-theme.css")) { + if (input == null) { + throw new IOException("Missing classpath resource css/light-theme.css"); + } + Files.copy(input, stylesheet); + } + return stylesheet.toUri().toString(); + } + + private static Label labelWithStyle(String styleClass) { + Label label = new Label(styleClass); + label.getStyleClass().add(styleClass); + return label; + } + + private static TableCell firstTableCell(TableView table) { + return table.lookupAll(".table-cell").stream() + .filter(TableCell.class::isInstance) + .map(TableCell.class::cast) + .findFirst() + .orElseThrow(); + } + + private static TreeCell firstTreeCell(TreeView tree) { + return tree.lookupAll(".tree-cell").stream() + .filter(TreeCell.class::isInstance) + .map(TreeCell.class::cast) + .findFirst() + .orElseThrow(); + } + + private static ListCell firstListCell(ListView list) { + return list.lookupAll(".list-cell").stream() + .filter(ListCell.class::isInstance) + .map(ListCell.class::cast) + .findFirst() + .orElseThrow(); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static Object computedCssValue(Styleable styleable, String property) { + CssMetaData metadata = styleable.getCssMetaData().stream() + .filter(candidate -> candidate.getProperty().equals(property)) + .findFirst() + .orElseThrow(() -> new AssertionError("Missing CSS property " + property)); + return metadata.getStyleableProperty(styleable).getValue(); + } + + private record Fixture( + Label pageTitle, + Label sectionTitle, + Label bodyText, + Label secondaryText, + TextArea logArea, + TableView table, + TreeView tree, + ListView compactList, + TabPane tabPane, + Button defaultButton, + TextField textField, + ComboBox comboBox, + Region header, + String stylesheetUrl) { + } + + private record ParserRun(T value, List errors) { + } + + @FunctionalInterface + private interface ThrowingSupplier { + T get() throws Exception; + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/MainWindowArchitectureTest.java b/src/test/java/com/example/homeutils/system/ui/MainWindowArchitectureTest.java new file mode 100644 index 0000000..1b36c20 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/MainWindowArchitectureTest.java @@ -0,0 +1,114 @@ +package com.example.homeutils.system.ui; + +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Constructor; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MainWindowArchitectureTest { + + @Test + void mainWindowRequiresAnExplicitSettingsAction() { + Constructor[] constructors = MainWindow.class.getConstructors(); + + assertEquals(1, constructors.length); + assertEquals(List.class, constructors[0].getParameterTypes()[0]); + assertEquals(com.example.homeutils.system.config.HomeUtilsProperties.class, + constructors[0].getParameterTypes()[1]); + assertEquals(Runnable.class, constructors[0].getParameterTypes()[2]); + } + + @Test + void settingsUsesTheExplicitShellActionWithoutBecomingABusinessItem() throws Exception { + String application = source("src/main/java/com/example/homeutils/HomeUtilsApplication.java"); + String buildNavItems = between(application, + "private List buildNavItems()", + "// ==================== 内嵌优先打开"); + String mainWindow = source("src/main/java/com/example/homeutils/system/ui/MainWindow.java"); + String navigationRail = source( + "src/main/java/com/example/homeutils/system/ui/shell/NavigationRail.java"); + + assertFalse(buildNavItems.contains("__settings__")); + assertFalse(buildNavItems.contains("系统设置")); + assertTrue(application.contains("mainWindow.refreshNavItems(buildNavItems())")); + assertTrue(application.contains( + "new MainWindow(navItems, properties, this::openSettingsEmbedded)")); + assertTrue(mainWindow.contains( + "MainWindow(List navItems, HomeUtilsProperties properties, Runnable openSettings)")); + assertTrue(mainWindow.contains("new NavigationRail(navItems, this::navigateTo, openSettings)")); + assertTrue(navigationRail.contains("openSettings.run()")); + } + + @Test + void refreshCallbackOnlyRebuildsNavigationAndNeverEagerlyEmbedsPluginPanes() throws Exception { + String application = source("src/main/java/com/example/homeutils/HomeUtilsApplication.java"); + String refreshCallback = between(application, + "settingsService.setNavItemsRefresher", + "List navItems = buildNavItems();"); + String buildNavItems = between(application, + "private List buildNavItems()", + "// ==================== 内嵌优先打开"); + String openPlugin = between(application, + "private void openPluginEmbedded", + "// ==================== 独立窗口模式"); + + assertTrue(refreshCallback.contains("mainWindow.refreshNavItems(buildNavItems())")); + assertFalse(refreshCallback.contains("getEmbeddedPane()")); + assertFalse(refreshCallback.contains("mainWindow.embedPlugin(")); + assertFalse(refreshCallback.contains("for (Plugin plugin")); + assertTrue(buildNavItems.contains("() -> openPluginEmbedded(pluginId, plugin, factory)")); + assertTrue(openPlugin.indexOf("plugin.checkConfig()") + < openPlugin.indexOf("factory.getEmbeddedPane()")); + } + + @Test + void headerUsesDesignTokensAndFeatherSemanticsWithoutCustomWindowChrome() throws Exception { + String mainWindow = source("src/main/java/com/example/homeutils/system/ui/MainWindow.java"); + String header = between(mainWindow, "private HBox buildUnifiedHeader", "private void navigateTo"); + + assertTrue(header.contains("DesignTokens.HEADER_HEIGHT")); + assertTrue(header.contains("IconFactory.create(AppIcon.APP")); + assertTrue(header.contains("IconFactory.create(AppIcon.CHEVRON_RIGHT")); + assertFalse(header.contains("AppIcon.MENU")); + assertFalse(header.contains("AppIcon.SETTINGS")); + assertFalse(header.contains("navigationRail.toggle()")); + assertFalse(header.contains("openSettings.run()")); + assertFalse(header.contains("toolbarIcon")); + assertFalse(header.contains("SVGPath")); + assertFalse(header.contains("= 0, () -> "Missing source marker: " + start); + assertTrue(endIndex > startIndex, () -> "Missing source marker: " + end); + return source.substring(startIndex, endIndex); + } + + private static int occurrences(String source, String needle) { + return source.split(java.util.regex.Pattern.quote(needle), -1).length - 1; + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java b/src/test/java/com/example/homeutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java new file mode 100644 index 0000000..e2e1134 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/NativeUiSceneVisualAcceptanceFixtureTest.java @@ -0,0 +1,118 @@ +package com.example.homeutils.system.ui; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class NativeUiSceneVisualAcceptanceFixtureTest { + + @Test + void capturesElevenDistinctRealJavaFxScenesWithoutSynthesizingNativeChrome() throws Exception { + Assumptions.assumeTrue(NativeUiVisualAcceptanceFixture.captureEnabled(), + () -> "Enable explicitly with -D" + + NativeUiVisualAcceptanceFixture.ENABLED_PROPERTY + "=true"); + assertNotNull(System.getProperty(NativeUiVisualAcceptanceFixture.OUTPUT_PROPERTY), + () -> "Missing -D" + NativeUiVisualAcceptanceFixture.OUTPUT_PROPERTY); + + Method capture = captureMethod(); + @SuppressWarnings("unchecked") + List captures = (List) capture.invoke(null); + + assertEquals(11, captures.size()); + List actualNames = new ArrayList<>(); + Set imageHashes = new HashSet<>(); + for (Object captured : captures) { + Method pathAccessor = captured.getClass().getDeclaredMethod("path"); + Method widthAccessor = captured.getClass().getDeclaredMethod("width"); + Method heightAccessor = captured.getClass().getDeclaredMethod("height"); + Path path = (Path) pathAccessor.invoke(captured); + int width = (int) widthAccessor.invoke(captured); + int height = (int) heightAccessor.invoke(captured); + BufferedImage image = ImageIO.read(path.toFile()); + + actualNames.add(path.getFileName().toString()); + assertTrue(Files.isRegularFile(path), () -> "Missing scene capture: " + path); + assertTrue(Files.size(path) > 0, () -> "Empty scene capture: " + path); + assertNotNull(image, () -> "Unreadable scene PNG: " + path); + assertEquals(image.getWidth(), width); + assertEquals(image.getHeight(), height); + assertSceneDimensions(path, width, height); + assertTrue(hasVisibleContent(image), + () -> "Scene snapshot is blank or black: " + path); + imageHashes.add(sha256(path)); + } + + assertEquals(List.of( + "scene-welcome-expanded-1440.png", + "scene-welcome-collapsed-1440.png", + "scene-running-1440.png", + "scene-welcome-expanded-1024.png", + "scene-welcome-collapsed-1024.png", + "scene-running-1024.png", + "scene-selected-expanded-1440.png", + "scene-selected-collapsed-1440.png", + "scene-selected-expanded-1024.png", + "scene-selected-collapsed-1024.png", + "scene-running-1100.png"), actualNames); + assertEquals(11, imageHashes.size(), + "Every real Scene state and width must produce a distinct image"); + } + + private static void assertSceneDimensions(Path path, int width, int height) { + String name = path.getFileName().toString(); + int stageWidth = name.contains("1440") ? 1440 : name.contains("1100") ? 1100 : 1024; + int stageHeight = name.contains("1440") ? 900 : 768; + assertTrue(width <= stageWidth && width >= stageWidth - 32, + () -> "Unexpected Scene width for " + path + ": " + width); + assertTrue(height <= stageHeight && height >= stageHeight - 80, + () -> "Unexpected Scene height for " + path + ": " + height); + } + + private static boolean hasVisibleContent(BufferedImage image) { + long samples = 0; + long visible = 0; + for (int y = 0; y < image.getHeight(); y += 16) { + for (int x = 0; x < image.getWidth(); x += 16) { + int rgb = image.getRGB(x, y); + int red = (rgb >>> 16) & 0xff; + int green = (rgb >>> 8) & 0xff; + int blue = rgb & 0xff; + samples++; + if (red > 32 || green > 32 || blue > 32) { + visible++; + } + } + } + return visible * 4 > samples; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(Files.readAllBytes(path))); + } + + private static Method captureMethod() { + try { + return NativeUiVisualAcceptanceFixture.class + .getDeclaredMethod("captureConfiguredSceneOutput"); + } catch (NoSuchMethodException missingCapture) { + return fail("Missing real JavaFX Scene snapshot entry point", missingCapture); + } + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixture.java b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixture.java new file mode 100644 index 0000000..aa644d9 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixture.java @@ -0,0 +1,510 @@ +package com.example.homeutils.system.ui; + +import com.example.homeutils.system.config.HomeUtilsProperties; +import com.example.homeutils.system.ui.async.AsyncOperationController; +import com.example.homeutils.system.ui.async.LoadingButtonBinder; +import com.example.homeutils.system.ui.async.OperationState; +import com.example.homeutils.system.ui.async.OperationStatusPane; +import com.example.homeutils.system.ui.icon.AppIcon; +import com.example.homeutils.system.ui.icon.IconFactory; +import com.example.homeutils.system.ui.history.ExportHistoryPane; +import com.example.homeutils.system.ui.history.HistoryFileCommands; +import com.example.homeutils.system.ui.history.HistoryItem; +import com.example.homeutils.system.ui.shell.NavigationRail; +import com.example.homeutils.system.ui.shell.PageHeader; +import javafx.collections.FXCollections; +import javafx.embed.swing.SwingFXUtils; +import javafx.geometry.Bounds; +import javafx.geometry.Insets; +import javafx.geometry.Rectangle2D; +import javafx.scene.Node; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBase; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ToggleButton; +import javafx.scene.image.WritableImage; +import javafx.scene.layout.VBox; +import javafx.scene.paint.Color; +import javafx.scene.robot.Robot; +import javafx.stage.Screen; +import javafx.stage.Stage; +import javafx.stage.StageStyle; + +import javax.imageio.ImageIO; +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.Executor; + +import org.kordamp.ikonli.javafx.FontIcon; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Explicitly enabled, test-only visual acceptance fixture for the real application shell. + * The fixture deliberately owns no plugin, configuration, network, Git, or export service. + */ +final class NativeUiVisualAcceptanceFixture { + + static final String ENABLED_PROPERTY = "homeutils.visualAcceptance.enabled"; + static final String OUTPUT_PROPERTY = "homeutils.visualAcceptance.outputDir"; + static final String HOLD_OPEN_PROPERTY = "homeutils.visualAcceptance.holdOpenMillis"; + + private static final List CAPTURE_SPECS = List.of( + new CaptureSpec("welcome-expanded-1440.png", View.WELCOME, true, 1440, 900), + new CaptureSpec("welcome-collapsed-1440.png", View.WELCOME, false, 1440, 900), + new CaptureSpec("running-1440.png", View.RUNNING, true, 1440, 900), + new CaptureSpec("welcome-expanded-1024.png", View.WELCOME, true, 1024, 768), + new CaptureSpec("welcome-collapsed-1024.png", View.WELCOME, false, 1024, 768), + new CaptureSpec("running-1024.png", View.RUNNING, true, 1024, 768)); + private static final List SCENE_CAPTURE_SPECS = sceneCaptureSpecs(); + + private static final List REQUIRED_IMAGE_NAMES = CAPTURE_SPECS.stream() + .map(CaptureSpec::fileName) + .toList(); + private static final List REQUIRED_SCENE_IMAGE_NAMES = SCENE_CAPTURE_SPECS.stream() + .map(spec -> "scene-" + spec.fileName()) + .toList(); + + static List requiredImageNames() { + return REQUIRED_IMAGE_NAMES; + } + + static List requiredSceneImageNames() { + return REQUIRED_SCENE_IMAGE_NAMES; + } + + static boolean captureEnabled() { + return "true".equals(System.getProperty(ENABLED_PROPERTY)); + } + + static List captureConfiguredOutput() throws Exception { + Path outputDirectory = configuredOutputDirectory("Native screenshot capture"); + FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory)); + try { + List captures = new ArrayList<>(); + for (CaptureSpec spec : CAPTURE_SPECS) { + FxTestSupport.fx(() -> fixture.configure(spec)); + settleNativeWindow(); + holdOpenIfRequested(spec); + captures.add(FxTestSupport.fx(() -> fixture.capture(spec))); + } + return List.copyOf(captures); + } finally { + FxTestSupport.fx(fixture::close); + } + } + + /** + * Captures the real JavaFX Scene tree only. Native window chrome is intentionally excluded + * and remains covered by the decorated-Stage assertions and Robot capture path above. + */ + static List captureConfiguredSceneOutput() throws Exception { + Path outputDirectory = configuredOutputDirectory("JavaFX Scene snapshot capture"); + List captures = new ArrayList<>(); + for (CaptureSpec spec : SCENE_CAPTURE_SPECS) { + FixtureWindow fixture = FxTestSupport.fx(() -> FixtureWindow.open(outputDirectory)); + try { + FxTestSupport.fx(() -> fixture.configure(spec)); + settleNativeWindow(); + captures.add(FxTestSupport.fx(() -> fixture.captureScene(spec))); + } finally { + FxTestSupport.fx(fixture::close); + } + } + return List.copyOf(captures); + } + + private static Path configuredOutputDirectory(String captureLabel) throws IOException { + if (!captureEnabled()) { + throw new IllegalStateException( + captureLabel + " requires -D" + ENABLED_PROPERTY + "=true"); + } + String configuredOutput = System.getProperty(OUTPUT_PROPERTY); + if (configuredOutput == null || configuredOutput.isBlank()) { + throw new IllegalStateException( + captureLabel + " requires -D" + OUTPUT_PROPERTY + "="); + } + Path outputDirectory = Path.of(configuredOutput).toAbsolutePath().normalize(); + Files.createDirectories(outputDirectory); + return outputDirectory; + } + + private static void settleNativeWindow() throws Exception { + FxTestSupport.fx(() -> { }); + Thread.sleep(200); + FxTestSupport.fx(() -> { }); + } + + static long holdOpenMillis() { + String configured = System.getProperty(HOLD_OPEN_PROPERTY); + if (configured == null || configured.isBlank()) { + return 0; + } + long duration; + try { + duration = Long.parseLong(configured); + } catch (NumberFormatException invalidDuration) { + throw new IllegalArgumentException( + HOLD_OPEN_PROPERTY + " must be an integer number of milliseconds", + invalidDuration); + } + if (duration < 0 || duration > 60_000) { + throw new IllegalArgumentException( + HOLD_OPEN_PROPERTY + " must be between 0 and 60000 milliseconds"); + } + return duration; + } + + private static void holdOpenIfRequested(CaptureSpec spec) throws InterruptedException { + long duration = holdOpenMillis(); + if (duration == 0) { + return; + } + System.out.printf("[visual-acceptance] holding %s for %d ms before Robot capture%n", + spec.fileName(), duration); + Thread.sleep(duration); + } + + record CapturedImage(Path path, int width, int height) { + } + + private enum View { + WELCOME, + SELECTED, + RUNNING + } + + private record CaptureSpec(String fileName, View view, boolean expanded, + int stageWidth, int stageHeight) { + } + + private static final class FixtureWindow { + + private final Path outputDirectory; + private final List navItems; + private final MainWindow mainWindow; + private final Stage stage; + private final NavigationRail navigationRail; + private final Label toolbarTitle; + private final ManualExecutor manualExecutor = new ManualExecutor(); + private final AsyncOperationController operationController = + new AsyncOperationController<>(manualExecutor, Runnable::run); + private final Button operationButton = new Button( + "开始生成", IconFactory.createButtonIcon(AppIcon.EXPORT, "开始生成")); + private final OperationStatusPane operationStatusPane = + new OperationStatusPane(operationController, false); + private final VBox runningPane; + private int apiDocOpens; + + private FixtureWindow(Path outputDirectory) throws ReflectiveOperationException { + this.outputDirectory = outputDirectory; + operationButton.setId("visual-fixture-operation"); + operationButton.getStyleClass().add(Theme.CSS_BTN_PRIMARY); + operationButton.setAccessibleText("开始生成视觉验收预览"); + LoadingButtonBinder.bind(operationButton, operationController, "开始生成"); + operationStatusPane.setId("visual-fixture-status"); + + ExportHistoryPane historyPane = new ExportHistoryPane( + "导出格式", + "文件数", + FXCollections.observableArrayList( + history("1", "HTML", "D:/exports/compensatory-core-api.html", "1", "2026-07-31 20:06:30"), + history("2", "HTML", "D:/exports/api_custom_batch.html", "1", "2026-07-31 20:05:42"), + history("3", "Markdown", "D:/exports/public-api.md", "8", "2026-07-31 20:05:36")), + new HistoryFileCommands( + ignored -> { }, ignored -> { }, ignored -> { }, ignored -> { }), + DesignTokens.FONT_BODY); + + runningPane = new VBox(Theme.S4, + new PageHeader("API 接口文档导出", "公共历史表格视觉验收"), + historyPane, + operationButton, + operationStatusPane); + runningPane.setPadding(new Insets(Theme.S6)); + + navItems = navigationItems(this::openApiDocFixture); + mainWindow = new MainWindow(navItems, new HomeUtilsProperties(), () -> { }); + stage = field(mainWindow, "stage", Stage.class); + navigationRail = field(mainWindow, "navigationRail", NavigationRail.class); + toolbarTitle = field(mainWindow, "toolbarTitle", Label.class); + + stage.setMinWidth(0); + stage.setMinHeight(0); + assertSame(StageStyle.DECORATED, stage.getStyle(), + "The fixture must retain the native decorated Stage"); + mainWindow.show(); + } + + private static HistoryItem history( + String index, String format, String file, String count, String time) { + return new HistoryItem(index, format, file, count, time); + } + + static FixtureWindow open(Path outputDirectory) throws ReflectiveOperationException { + assertTrue(javafx.application.Platform.isFxApplicationThread()); + Theme.initTheme(); + return new FixtureWindow(outputDirectory); + } + + void configure(CaptureSpec spec) { + assertTrue(javafx.application.Platform.isFxApplicationThread()); + ensureRequestedSizeFitsDesktop(spec); + stage.setWidth(spec.stageWidth()); + stage.setHeight(spec.stageHeight()); + centerOnPrimaryScreen(spec); + + if (spec.view() == View.WELCOME) { + mainWindow.refreshNavItems(navItems); + } else { + showApiDocState(spec.view() == View.RUNNING); + } + navigationRail.setExpanded(spec.expanded()); + stage.toFront(); + stage.requestFocus(); + stage.getScene().getRoot().applyCss(); + stage.getScene().getRoot().layout(); + validateShell(spec); + } + + CapturedImage capture(CaptureSpec spec) throws IOException { + assertTrue(javafx.application.Platform.isFxApplicationThread()); + stage.getScene().getRoot().applyCss(); + stage.getScene().getRoot().layout(); + validateShell(spec); + + WritableImage image = new Robot().getScreenCapture( + null, stage.getX(), stage.getY(), stage.getWidth(), stage.getHeight(), false); + Path target = outputDirectory.resolve(spec.fileName()); + boolean written = ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", + target.toFile()); + assertTrue(written, () -> "No PNG writer for " + target); + int actualWidth = (int) Math.round(image.getWidth()); + int actualHeight = (int) Math.round(image.getHeight()); + assertTrue(Files.isRegularFile(target), () -> "Missing capture: " + target); + assertTrue(Files.size(target) > 0, () -> "Empty capture: " + target); + assertTrue(actualWidth >= spec.stageWidth(), + () -> "Capture width " + actualWidth + " < " + spec.stageWidth()); + assertTrue(actualHeight >= spec.stageHeight() - 1, + () -> "Capture height " + actualHeight + " < " + spec.stageHeight()); + return new CapturedImage(target, actualWidth, actualHeight); + } + + CapturedImage captureScene(CaptureSpec spec) throws IOException { + assertTrue(javafx.application.Platform.isFxApplicationThread()); + Scene scene = stage.getScene(); + scene.getRoot().applyCss(); + scene.getRoot().layout(); + validateShell(spec); + + WritableImage image = scene.snapshot(null); + Path target = outputDirectory.resolve("scene-" + spec.fileName()); + boolean written = ImageIO.write(SwingFXUtils.fromFXImage(image, null), "png", + target.toFile()); + assertTrue(written, () -> "No PNG writer for " + target); + int actualWidth = (int) Math.round(image.getWidth()); + int actualHeight = (int) Math.round(image.getHeight()); + assertEquals(scene.getWidth(), actualWidth, 1.0, + "Scene snapshot width must match the real Scene"); + assertEquals(scene.getHeight(), actualHeight, 1.0, + "Scene snapshot height must match the real Scene"); + assertTrue(Files.isRegularFile(target), () -> "Missing Scene capture: " + target); + assertTrue(Files.size(target) > 0, () -> "Empty Scene capture: " + target); + return new CapturedImage(target, actualWidth, actualHeight); + } + + void close() { + operationStatusPane.dispose(); + stage.close(); + } + + private void showApiDocState(boolean running) { + int opensBefore = apiDocOpens; + navigationRail.selectPlugin("api-doc"); + assertEquals(opensBefore + 1, apiDocOpens, + "The safe api-doc fixture action must run once per selection"); + if (running && operationController.snapshot().state() != OperationState.RUNNING) { + operationController.start("正在生成预览", context -> null, ignored -> { }); + } + if (running) { + assertEquals(OperationState.RUNNING, operationController.snapshot().state()); + assertTrue(manualExecutor.hasPendingTask(), + "The executor must hold the operation without executing it"); + } else { + assertEquals(OperationState.IDLE, operationController.snapshot().state()); + assertFalse(manualExecutor.hasPendingTask()); + } + } + + private void openApiDocFixture() { + apiDocOpens++; + mainWindow.embedPlugin( + "api-doc", null, "API 接口文档导出", runningPane); + } + + private void validateShell(CaptureSpec spec) { + assertTrue(stage.isShowing()); + assertSame(StageStyle.DECORATED, stage.getStyle()); + assertEquals(spec.stageWidth(), stage.getWidth(), 1.0, + "Stage width must equal the requested content width"); + assertEquals(spec.stageHeight(), stage.getHeight(), 1.0); + assertEquals(spec.expanded() ? DesignTokens.NAV_EXPANDED_WIDTH + : DesignTokens.NAV_COLLAPSED_WIDTH, + navigationRail.getWidth(), 0.5); + + Scene scene = stage.getScene(); + Node header = scene.lookup("#app-header"); + assertNotNull(header); + assertEquals(DesignTokens.HEADER_HEIGHT, header.getBoundsInParent().getHeight(), 0.5); + validateSettingsControl(scene, header); + + if (spec.view() != View.WELCOME) { + validateSelectedApiDocState(scene); + } + + if (spec.view() == View.RUNNING) { + assertEquals(OperationState.RUNNING, operationController.snapshot().state()); + assertTrue(operationButton.isVisible()); + assertTrue(operationButton.getGraphic() instanceof ProgressIndicator, + "LoadingButtonBinder must expose its real spinner"); + assertTrue(operationButton.getText().contains("正在生成预览")); + assertTrue(operationStatusPane.isVisible()); + assertTrue(operationStatusPane.isManaged()); + } + } + + private void validateSelectedApiDocState(Scene scene) { + ToggleButton apiDoc = (ToggleButton) scene.lookup("#nav-api-doc"); + assertNotNull(apiDoc); + assertTrue(apiDoc.isSelected()); + assertEquals("api-doc", navigationRail.selectedPluginId()); + assertTrue(apiDoc.getStyleClass().contains(Theme.CSS_NAV_CELL_ACTIVE)); + assertEquals(Color.web("#dbeafe"), + apiDoc.getBackground().getFills().get(0).getFill()); + assertEquals(3, + apiDoc.getBorder().getStrokes().get(0).getWidths().getLeft()); + assertEquals(Color.web("#2563eb"), apiDoc.getTextFill()); + assertEquals(Color.web("#2563eb"), + ((FontIcon) apiDoc.getGraphic()).getIconColor()); + assertTrue(toolbarTitle.isVisible()); + assertEquals("API 接口文档导出", toolbarTitle.getText()); + } + + private void validateSettingsControl(Scene scene, Node header) { + ButtonBase settings = (ButtonBase) scene.lookup("#navigation-rail-settings"); + assertNotNull(settings); + assertTrue(settings.isVisible()); + assertSame(settings, navigationRail.lookup("#navigation-rail-settings")); + assertFalse(descendants((Parent) header).contains(settings)); + assertEquals("系统设置", settings.getAccessibleText()); + Bounds position = settings.localToScene(settings.getBoundsInLocal()); + assertTrue(position.getMaxX() <= navigationRail.getWidth() + 1, + "System settings must remain inside the navigation rail"); + assertNull(scene.lookup("#settings-button")); + assertNull(scene.lookup("#menu-button")); + } + + private void ensureRequestedSizeFitsDesktop(CaptureSpec spec) { + Rectangle2D desktop = Screen.getPrimary().getVisualBounds(); + assertTrue(desktop.getWidth() >= spec.stageWidth(), + () -> "Desktop width " + desktop.getWidth() + + " cannot fit requested Stage width " + spec.stageWidth()); + assertTrue(desktop.getHeight() >= spec.stageHeight(), + () -> "Desktop height " + desktop.getHeight() + + " cannot fit requested Stage height " + spec.stageHeight()); + } + + private void centerOnPrimaryScreen(CaptureSpec spec) { + Rectangle2D desktop = Screen.getPrimary().getVisualBounds(); + stage.setX(desktop.getMinX() + (desktop.getWidth() - spec.stageWidth()) / 2); + stage.setY(desktop.getMinY() + (desktop.getHeight() - spec.stageHeight()) / 2); + } + + private static T field(Object target, String name, Class type) + throws ReflectiveOperationException { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return type.cast(field.get(target)); + } + } + + static List navigationItems(Runnable openApiDoc) { + return List.of( + nav(AppIcon.GIT_SYNC, "Git仓库同步", "同步本地仓库", "git-sync"), + nav(AppIcon.GIT_REVIEW, "Git分支评审", "评审分支变更", "git-review"), + nav(AppIcon.DIFF, "文本对比", "比较文本差异", "diff"), + nav(AppIcon.SQL_EXPORT, "MyBatis SQL导出", "导出查询结果", "mybatisexport"), + nav(AppIcon.MYBATIS_GENERATOR, "MyBatis逆向工程", "生成 MyBatis 代码", + "mybatisgenerator"), + nav(AppIcon.SCANNER, "扫描功能", "扫描代码关键词", "scan"), + new MainWindow.NavItem(AppIcon.API_DOC, "API 接口文档导出", + "导出接口文档", openApiDoc, "api-doc")); + } + + private static MainWindow.NavItem nav(AppIcon icon, String name, + String description, String pluginId) { + return new MainWindow.NavItem(icon, name, description, () -> { }, pluginId); + } + + private static List sceneCaptureSpecs() { + List specs = new ArrayList<>(CAPTURE_SPECS); + specs.add(new CaptureSpec( + "selected-expanded-1440.png", View.SELECTED, true, 1440, 900)); + specs.add(new CaptureSpec( + "selected-collapsed-1440.png", View.SELECTED, false, 1440, 900)); + specs.add(new CaptureSpec( + "selected-expanded-1024.png", View.SELECTED, true, 1024, 768)); + specs.add(new CaptureSpec( + "selected-collapsed-1024.png", View.SELECTED, false, 1024, 768)); + specs.add(new CaptureSpec( + "running-1100.png", View.RUNNING, true, 1100, 768)); + return List.copyOf(specs); + } + + private static List descendants(Parent parent) { + List descendants = new ArrayList<>(); + Deque pending = new ArrayDeque<>(parent.getChildrenUnmodifiable()); + while (!pending.isEmpty()) { + Node node = pending.removeFirst(); + descendants.add(node); + if (node instanceof Parent childParent) { + pending.addAll(childParent.getChildrenUnmodifiable()); + } + } + return descendants; + } + + private static final class ManualExecutor implements Executor { + + private Runnable pendingTask; + + @Override + public void execute(Runnable command) { + if (pendingTask != null) { + throw new IllegalStateException("Only one pending visual fixture task is allowed"); + } + pendingTask = Objects.requireNonNull(command, "command"); + } + + boolean hasPendingTask() { + return pendingTask != null; + } + } + + private NativeUiVisualAcceptanceFixture() { + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java new file mode 100644 index 0000000..113463b --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureContractTest.java @@ -0,0 +1,132 @@ +package com.example.homeutils.system.ui; + +import com.example.homeutils.system.ui.shell.NavigationRail; +import javafx.scene.Scene; +import javafx.scene.control.ToggleButton; +import javafx.scene.layout.VBox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; + +import static com.example.homeutils.system.ui.FxTestSupport.fx; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class NativeUiVisualAcceptanceFixtureContractTest { + + private static final String ENABLED_PROPERTY = + "homeutils.visualAcceptance.enabled"; + private static final String HOLD_OPEN_PROPERTY = + "homeutils.visualAcceptance.holdOpenMillis"; + + @AfterEach + void clearCaptureOptIn() { + System.clearProperty(ENABLED_PROPERTY); + System.clearProperty(HOLD_OPEN_PROPERTY); + } + + @Test + void fixtureDeclaresEveryRequiredNativeCapture() throws Exception { + Class fixture = loadFixture(); + Method requiredImageNames = fixture.getDeclaredMethod("requiredImageNames"); + + assertEquals(List.of( + "welcome-expanded-1440.png", + "welcome-collapsed-1440.png", + "running-1440.png", + "welcome-expanded-1024.png", + "welcome-collapsed-1024.png", + "running-1024.png"), + requiredImageNames.invoke(null)); + } + + @Test + void fixtureDeclaresEveryRequiredRealSceneCapture() throws Exception { + Method requiredSceneImageNames = method("requiredSceneImageNames"); + + assertEquals(List.of( + "scene-welcome-expanded-1440.png", + "scene-welcome-collapsed-1440.png", + "scene-running-1440.png", + "scene-welcome-expanded-1024.png", + "scene-welcome-collapsed-1024.png", + "scene-running-1024.png", + "scene-selected-expanded-1440.png", + "scene-selected-collapsed-1440.png", + "scene-selected-expanded-1024.png", + "scene-selected-collapsed-1024.png", + "scene-running-1100.png"), + requiredSceneImageNames.invoke(null)); + } + + @Test + void fixtureRendersTheApprovedSevenNavigationLabelsInOrder() throws Exception { + List renderedLabels = fx(() -> { + NavigationRail rail = new NavigationRail( + NativeUiVisualAcceptanceFixture.navigationItems(() -> { }), item -> { }, () -> { }); + VBox root = new VBox(rail); + new Scene(root, DesignTokens.NAV_EXPANDED_WIDTH, 480); + root.applyCss(); + root.layout(); + + VBox itemBox = assertInstanceOf( + VBox.class, rail.lookup(".navigation-rail-items")); + return itemBox.getChildren().stream() + .map(ToggleButton.class::cast) + .map(ToggleButton::getText) + .toList(); + }); + + assertEquals(List.of( + "Git仓库同步", + "Git分支评审", + "文本对比", + "MyBatis SQL导出", + "MyBatis逆向工程", + "扫描功能", + "API 接口文档导出"), renderedLabels); + } + + @Test + void fixtureIsDisabledUnlessTheExplicitPropertyIsTrue() throws Exception { + Method captureEnabled = method("captureEnabled"); + + assertFalse((boolean) captureEnabled.invoke(null)); + System.setProperty(ENABLED_PROPERTY, "true"); + assertTrue((boolean) captureEnabled.invoke(null)); + System.setProperty(ENABLED_PROPERTY, "yes"); + assertFalse((boolean) captureEnabled.invoke(null)); + } + + @Test + void holdOpenDefaultsToZeroAndRequiresAnExplicitDuration() throws Exception { + Method holdOpenMillis = method("holdOpenMillis"); + + assertEquals(0L, holdOpenMillis.invoke(null)); + System.setProperty(HOLD_OPEN_PROPERTY, "1500"); + assertEquals(1500L, holdOpenMillis.invoke(null)); + } + + private static Method method(String name) { + try { + return loadFixture().getDeclaredMethod(name); + } catch (NoSuchMethodException missingContract) { + return fail("Missing fixture contract method: " + name, missingContract); + } + } + + private static Class loadFixture() { + try { + return Class.forName( + "com.example.homeutils.system.ui.NativeUiVisualAcceptanceFixture"); + } catch (ClassNotFoundException missingFixture) { + return fail("Missing reusable native JavaFX visual acceptance fixture", + missingFixture); + } + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java new file mode 100644 index 0000000..ced9d62 --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/NativeUiVisualAcceptanceFixtureTest.java @@ -0,0 +1,93 @@ +package com.example.homeutils.system.ui; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.awt.image.BufferedImage; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +import javax.imageio.ImageIO; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +class NativeUiVisualAcceptanceFixtureTest { + + private static final String OUTPUT_PROPERTY = + "homeutils.visualAcceptance.outputDir"; + + @Test + void capturesSixDecoratedSideEffectFreeShellStates() throws Exception { + Assumptions.assumeTrue(NativeUiVisualAcceptanceFixture.captureEnabled(), + () -> "Enable explicitly with -D" + + NativeUiVisualAcceptanceFixture.ENABLED_PROPERTY + "=true"); + assertNotNull(System.getProperty(OUTPUT_PROPERTY), + () -> "Missing -D" + OUTPUT_PROPERTY); + + Method capture = captureMethod(); + @SuppressWarnings("unchecked") + List captures = (List) capture.invoke(null); + + assertEquals(6, captures.size()); + Set imageHashes = new HashSet<>(); + for (Object captured : captures) { + Method pathAccessor = captured.getClass().getDeclaredMethod("path"); + Method widthAccessor = captured.getClass().getDeclaredMethod("width"); + Method heightAccessor = captured.getClass().getDeclaredMethod("height"); + Path path = (Path) pathAccessor.invoke(captured); + int width = (int) widthAccessor.invoke(captured); + int height = (int) heightAccessor.invoke(captured); + assertTrue(Files.isRegularFile(path), () -> "Missing capture: " + path); + assertTrue(Files.size(path) > 0, () -> "Empty capture: " + path); + assertTrue(width >= 1024, () -> "Capture too narrow: " + path + " " + width); + assertTrue(height >= 700, () -> "Capture too short: " + path + " " + height); + assertTrue(hasVisibleDesktopContent(path), + () -> "Robot returned a blank or black desktop capture: " + path); + imageHashes.add(sha256(path)); + } + assertEquals(6, imageHashes.size(), + "Every requested UI state and width must produce a distinct capture"); + } + + private static Method captureMethod() { + try { + return NativeUiVisualAcceptanceFixture.class + .getDeclaredMethod("captureConfiguredOutput"); + } catch (NoSuchMethodException missingCapture) { + return fail("Missing native JavaFX capture entry point", missingCapture); + } + } + + private static boolean hasVisibleDesktopContent(Path path) throws Exception { + BufferedImage image = ImageIO.read(path.toFile()); + long samples = 0; + long visible = 0; + for (int y = 0; y < image.getHeight(); y += 16) { + for (int x = 0; x < image.getWidth(); x += 16) { + int rgb = image.getRGB(x, y); + int red = (rgb >>> 16) & 0xff; + int green = (rgb >>> 8) & 0xff; + int blue = rgb & 0xff; + samples++; + if (red > 32 || green > 32 || blue > 32) { + visible++; + } + } + } + return visible * 4 > samples; + } + + private static String sha256(Path path) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(Files.readAllBytes(path))); + } +} diff --git a/src/test/java/com/example/homeutils/system/ui/SharedPaneAccessibilityTest.java b/src/test/java/com/example/homeutils/system/ui/SharedPaneAccessibilityTest.java new file mode 100644 index 0000000..27df10a --- /dev/null +++ b/src/test/java/com/example/homeutils/system/ui/SharedPaneAccessibilityTest.java @@ -0,0 +1,193 @@ +package com.example.homeutils.system.ui; + +import com.example.homeutils.system.ui.async.AsyncOperationController; +import com.example.homeutils.system.ui.async.OperationState; +import com.example.homeutils.system.ui.async.OperationStatusPane; +import com.example.homeutils.system.ui.history.ExportHistoryPane; +import com.example.homeutils.system.ui.history.HistoryFileCommands; +import com.example.homeutils.system.ui.history.HistoryItem; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.scene.control.Button; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.StackPane; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.FutureTask; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class SharedPaneAccessibilityTest { + + @BeforeAll + static void startJavaFx() throws Exception { + CountDownLatch started = new CountDownLatch(1); + try { + Platform.startup(started::countDown); + } catch (IllegalStateException alreadyStarted) { + started.countDown(); + } + started.await(); + } + + @Test + void emptyStateExposesCombinedDescriptionWithoutTakingFocus() throws Exception { + runFx(() -> { + EmptyStatePane pane = new EmptyStatePane( + new EmptyStatePane.Content("暂无结果", "请先执行任务", ""), + null, + 14); + + assertEquals("暂无结果。请先执行任务", pane.getAccessibleText()); + assertFalse(pane.isFocusTraversable()); + }); + } + + @Test + void nonCancellableStatusHasNoHiddenButton() throws Exception { + runFx(() -> { + AsyncOperationController controller = + new AsyncOperationController<>(Runnable::run, Runnable::run); + OperationStatusPane pane = new OperationStatusPane(controller, false); + + long buttons = pane.getChildren().stream() + .filter(Button.class::isInstance) + .count(); + + assertEquals(0, buttons); + assertEquals("任务状态:就绪", pane.getAccessibleText()); + }); + } + + @Test + void determinateStatusShowsPercentAndCancellableButtonRequestsCancellation() + throws Exception { + runFx(() -> { + AtomicReference pendingOperation = new AtomicReference<>(); + List uiUpdates = new ArrayList<>(); + AsyncOperationController controller = + new AsyncOperationController<>(pendingOperation::set, uiUpdates::add); + OperationStatusPane pane = new OperationStatusPane(controller, true); + assertNull(pane.getScene(), + "an initially unattached pane must still observe its controller"); + + List