commit 2c23679ebb0201b56c20398e5d36cf4e3faaf737 Author: yiciheng Date: Sun Aug 16 11:52:52 2026 +0800 feat: 微信 AI 助手插件(wechat-ai)基线 - 移除旧 aitest 与 mobile-ui-auto 插件及其资源、测试 - 新增纯 Java 微信 AI 助手插件(ADB 驱动 + AI 回复) - 同步更新主界面图标映射、主题样式与插件配置 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 0000000..cb28b0e Binary files /dev/null and b/.mvn/wrapper/maven-wrapper.jar differ 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 0000000..d6b5568 Binary files /dev/null and b/src/main/resources/app_icon.ico differ diff --git a/src/main/resources/app_icon.png b/src/main/resources/app_icon.png new file mode 100644 index 0000000..0d8af0a Binary files /dev/null and b/src/main/resources/app_icon.png differ 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' + + '
\n' + + ' 🔧 Dubbo 调试快照\n' + + ' 导出时间: ' + escHtml(ts) + '\n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '
接口信息
\n' + + ' \n' + + ' \n' + + ' \n' + + ' \n' + + '
接口' + iface + '
方法' + method + '
参数类型' + escHtml(paramTypes) + '
\n' + + '
\n' + + '
\n' + + '
调试参数
\n' + + '
\n' + + '
\n' + + ' \n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '
请求参数\n' + + ' \n' + + ' \n' + + ' \n' + + '
\n' + + ' \n' + + '
\n' + + '
\n' + + ' \n' + + ' \n' + + ' \n' + + '
\n' + + '
\n' + + '
\n' + + '
\n' + + '
📥 响应结果
\n' + + '
' + (respStatus||'等待发送...') + '
\n' + + (respTime ? '
' + respTime + '
\n' : '') + + '
' + respBody + '
\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