diff --git a/README.md b/README.md index 59bf4f9..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,29 +0,0 @@ -# WorkUtils — 开发工作集 - -一站式开发工具集桌面应用。 - -## 功能模块 - -| 模块 | 说明 | -|------|------| -| **文本对比** | 基于 LCS 算法的文本差异对比 | -| **MyBatis 逆向工程** | 根据数据库表生成 PO / Mapper / XML | -| **Git 仓库同步** | 批量切换分支、拉取最新代码 | -| **扫描功能** | 关键词 / Redis / Dubbo 扫描 | -| **API 接口文档导出** | 解析 Java 源码,导出 Dubbo / Spring 接口文档(HTML / Markdown) | -| **MyBatis SQL 导出** | 从 MyBatis XML 提取 SQL 并导出 CSV | -| **系统设置** | 配置数据库、项目路径、数据源、字体等 | - -## 运行 - -``` -双击 workutils.exe,或执行 java -jar workutils-{version}.jar -``` - -## 构建 - -``` -scripts\build_release.bat -``` - -需要:JDK 17 + JavaFX SDK 17,设置环境变量 `JDK17_HOME` 和 `JFX_SDK_HOME`。 diff --git a/docs/ui-automation-plugin-design.md b/docs/ui-automation-plugin-design.md new file mode 100644 index 0000000..53b2f17 --- /dev/null +++ b/docs/ui-automation-plugin-design.md @@ -0,0 +1,760 @@ +# UI 自动化插件设计方案 + +> 插件名称建议:手机 UI 自动化 +> 插件 ID 建议:`mobile-ui-auto` +> 核心技术栈:JavaFX 插件壳 + Python Runner + Airtest + Poco(pocoui) + ADB +> 目标:在 WorkUtils 中提供一个可视化入口,用于连接 Android 手机,对指定 APP 执行自动点击、输入、滑动、断言、截图和流程测试。 + +## 1. 设计结论 + +本插件不建议在 Java 里直接实现手机 UI 自动化细节,而是采用“双层架构”: + +```text +WorkUtils Java 插件层 + 负责:可视化配置、脚本管理、流程编排、进度展示、报告入口、进程控制 + +Python 自动化执行层 + 负责:连接设备、启动 APP、查找元素/图片、点击输入、断言、截图、生成运行结果 +``` + +选择理由: + +- `pocoui/Poco` 和 `Airtest` 原生就是 Python 生态,直接用 Python 写动作最顺。 +- Java 项目已有插件框架,适合做工具入口、配置管理、运行控制和报告汇总。 +- 手机自动化经常依赖 ADB、截图、图片模板、设备状态,这些动作放在独立 Python 进程中更容易隔离异常。 +- 后续如果要加 `uiautomator2`、Appium 或云真机,可以扩展 Python Runner,不破坏 Java 插件壳。 + +## 2. 能力边界 + +### 2.1 首期能力 + +| 能力 | 说明 | +|------|------| +| 设备检测 | 通过 ADB 列出已连接设备,展示 serial、状态、系统版本 | +| APP 启动 | 输入 package/activity 或仅 package,启动被测 APP | +| 自动点击 | 支持元素定位点击、图片定位点击、坐标点击 | +| 自动输入 | 支持输入文本、清空输入框、粘贴式输入 | +| 手势模拟 | 支持滑动、返回、Home、等待、按键事件 | +| 元素定位 | 优先 Poco 元素定位,必要时使用图片识别兜底 | +| 流程脚本 | 支持 YAML/JSON 步骤流,自动转 Python 执行 | +| Python 脚本 | 支持直接运行 `.py` 或 `.air` 脚本 | +| 运行日志 | Java UI 实时显示 Python stdout/stderr | +| 截图报告 | 每个关键步骤可保存截图,失败时自动截图 | +| 断言 | 支持元素存在、图片存在、文本存在、页面等待 | + +### 2.2 非首期能力 + +| 能力 | 放到后续原因 | +|------|--------------| +| iOS 自动化 | 依赖环境复杂,首期聚焦 Android | +| 分布式设备池 | 需要设备调度、队列和并发报告模型 | +| 录制回放 | 可以实现,建议第二阶段加入;首期先保证脚本执行闭环 | +| 智能探索测试 | 需要页面状态建模和策略引擎 | +| Appium 兼容 | 跨平台价值高,但启动成本比 Poco 高 | + +## 3. 推荐技术组合 + +### 3.1 主链路 + +```text +Python + Airtest + Poco(pocoui) + ADB +``` + +- Airtest:设备连接、截图、图像识别、触控、断言、报告。 +- Poco(pocoui):UI 树定位、元素点击、输入、等待。 +- ADB:设备列表、安装 APK、启动 APP、logcat、底层命令。 +- AirtestIDE:作为辅助工具,用于新手录制/定位图片/生成初版脚本。 + +### 3.2 兜底链路 + +```text +uiautomator2 +``` + +首期只做预留,不默认引入。适合 Poco 在部分原生控件上拿不到稳定 UI 层级时,作为元素定位增强。 + +## 4. 总体架构 + +```mermaid +flowchart LR + A["MainWindow / PluginManager"] --> B["MobileUiAutomationPlugin"] + B --> C["MobileUiAutomationWindow"] + B --> D["MobileAutomationService"] + D --> E["PythonEnvManager"] + D --> F["DeviceService"] + D --> G["CaseService"] + D --> H["PythonRunner"] + H --> I["runner.py"] + I --> J["Airtest API"] + I --> K["Poco AndroidUiautomationPoco"] + I --> L["ADB"] + J --> M["Android Device"] + K --> M + L --> M + I --> N["reports / screenshots / result.json"] + H --> O["实时日志回调"] + O --> C +``` + +## 5. Java 插件层设计 + +### 5.1 包结构 + +```text +src/main/java/com/example/workutils/biz/mobileuiauto/ + MobileUiAutomationPlugin.java + MobileUiAutomationWindow.java + service/ + MobileAutomationService.java + PythonEnvManager.java + DeviceService.java + CaseService.java + PythonRunner.java + model/ + DeviceInfo.java + AutomationCase.java + AutomationRunConfig.java + AutomationRunResult.java + AutomationStep.java +``` + +### 5.2 资源和脚本结构 + +```text +src/main/resources/mobile-ui-auto/ + python/ + runner.py + requirements.txt + flow_executor.py + action_library.py + templates/ + sample-login-flow.yaml + sample-case.py +``` + +运行时目录: + +```text +data/mobile-ui-auto/ + config.json + cases/ + login-flow.yaml + smoke-test.py + images/ + login_button.png + home_tab.png + reports/ + 20260703-153000/ + result.json + run.log + screenshots/ + airtest-report/ +``` + +### 5.3 插件描述 + +```java +private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() + .id("mobile-ui-auto") + .name("手机UI自动化") + .version("1.0.0") + .description("基于 Python + Airtest + Poco + ADB 的手机 APP 流程自动化测试工具") + .order(80) + .icon("📱") + .build(); +``` + +项目里部分中文注释目前存在编码显示异常,实际落地时建议统一以 UTF-8 保存源码和文档,避免 UI 文案乱码。 + +## 6. JavaFX 界面设计 + +插件采用嵌入式 JavaFX 面板,分为 5 个区域: + +```text +┌──────────────────────────────────────────────────────────────┐ +│ 顶部工具栏:设备刷新 | Python环境检查 | 打开用例目录 | 打开报告目录 │ +├───────────────┬──────────────────────────────────────────────┤ +│ 左侧设备区 │ 右侧执行区 │ +│ - 设备列表 │ - 被测APP package/activity │ +│ - 当前设备 │ - 用例选择 YAML/PY/.air │ +│ - 连接状态 │ - 运行参数:超时、失败截图、报告开关 │ +├───────────────┼──────────────────────────────────────────────┤ +│ 用例列表 │ 步骤预览 / 脚本预览 │ +├───────────────┴──────────────────────────────────────────────┤ +│ 底部日志区:实时日志、进度、失败原因、报告链接 │ +└──────────────────────────────────────────────────────────────┘ +``` + +### 6.1 页面控件 + +| 区域 | 控件 | +|------|------| +| 设备区 | `ComboBox`、刷新按钮、设备状态标签 | +| APP 配置 | package 输入框、activity 输入框、启动 APP 按钮 | +| 用例区 | 用例表格、新建/导入/删除/复制按钮 | +| 执行区 | 运行、停止、保存配置、打开报告按钮 | +| 日志区 | `TextArea` 实时追加日志,错误行高亮 | + +### 6.2 用户流程 + +```text +1. 打开“手机UI自动化”插件 +2. 点击“检查环境” +3. 连接手机并打开 USB 调试 +4. 点击“刷新设备” +5. 选择设备 +6. 填写或选择 APP package +7. 选择流程用例 +8. 点击“运行” +9. 查看实时日志、截图、结果 +10. 失败时打开报告定位问题 +``` + +## 7. Python 执行层设计 + +### 7.1 requirements.txt + +```text +airtest +pocoui +pyyaml +``` + +说明: + +- `airtest` 提供设备连接、点击、滑动、截图、图片断言、报告能力。 +- `pocoui` 提供 Poco UI 层级定位能力。 +- `pyyaml` 用于读取流程式 YAML 用例。 + +### 7.2 runner.py 入参 + +Java 通过 `ProcessBuilder` 调用 Python: + +```text +python runner.py + --device Android:/// + --package com.xxx.app + --activity .MainActivity + --case data/mobile-ui-auto/cases/login-flow.yaml + --report data/mobile-ui-auto/reports/20260703-153000 + --timeout 20 +``` + +### 7.3 runner.py 输出协议 + +Python 进程按行输出 JSON,Java 端解析后更新 UI: + +```json +{"type":"log","level":"INFO","message":"连接设备成功"} +{"type":"step_start","index":1,"name":"点击登录按钮"} +{"type":"screenshot","path":"reports/xxx/screenshots/001.png"} +{"type":"step_pass","index":1,"elapsedMs":532} +{"type":"step_fail","index":2,"message":"元素不存在: username"} +{"type":"done","success":false,"reportPath":"reports/xxx/result.json"} +``` + +如果输出不是 JSON,则按普通日志展示,避免第三方库日志破坏执行。 + +## 8. 流程用例 DSL 设计 + +首期建议用 YAML 描述流程,便于非 Python 用户维护。 + +### 8.1 示例:登录流程 + +```yaml +name: login-smoke +app: + package: com.example.app + activity: .MainActivity +device: + connect: Android:/// +settings: + timeout: 20 + screenshotOnFail: true +steps: + - name: 等待首页 + action: wait + target: + type: poco + query: + text: 首页 + timeout: 15 + + - name: 点击我的 + action: tap + target: + type: poco + query: + text: 我的 + + - name: 输入用户名 + action: input + target: + type: poco + query: + name: username_input + text: test_user + + - name: 输入密码 + action: input + target: + type: poco + query: + name: password_input + text: "123456" + + - name: 点击登录 + action: tap + target: + type: image + path: images/login_button.png + + - name: 断言登录成功 + action: assert_exists + target: + type: poco + query: + text: 退出登录 +``` + +### 8.2 支持动作 + +| action | 说明 | target | +|--------|------|--------| +| `start_app` | 启动 APP | package/activity | +| `stop_app` | 停止 APP | package | +| `tap` | 点击 | poco/image/coord | +| `double_tap` | 双击 | poco/image/coord | +| `input` | 输入文本 | poco | +| `clear` | 清空输入框 | poco | +| `swipe` | 滑动 | coord/image | +| `wait` | 等待元素出现 | poco/image | +| `sleep` | 固定等待 | seconds | +| `keyevent` | Android 按键 | BACK/HOME/ENTER | +| `screenshot` | 截图 | path | +| `assert_exists` | 断言存在 | poco/image | +| `assert_not_exists` | 断言不存在 | poco/image | + +### 8.3 定位方式 + +```yaml +target: + type: poco + query: + text: 登录 +``` + +```yaml +target: + type: poco + query: + name: com.example:id/login +``` + +```yaml +target: + type: image + path: images/login_button.png + threshold: 0.85 +``` + +```yaml +target: + type: coord + x: 540 + y: 1600 +``` + +推荐优先级: + +```text +poco 元素定位 > 图片定位 > 坐标定位 +``` + +坐标定位只做兜底,因为不同分辨率、字体缩放和设备比例会导致脚本脆弱。 + +## 9. 录制回放设计 + +可以实现“手动模拟点击后记录下来,后续按记录自动模拟”的能力。推荐把它设计成“录制器 + 定位增强 + YAML 生成 + 回放校验”,而不是只保存点击坐标。 + +### 9.1 录制目标 + +用户在手机上手动操作一遍 APP,插件自动记录: + +- 点击、长按、滑动、返回、Home、输入文本等事件。 +- 每一步操作前后的截图。 +- 当前页面 UI 层级信息。 +- 被点击元素的 Poco 定位信息。 +- 坐标和屏幕分辨率,作为兜底。 +- 步骤间等待时间。 + +录制结束后生成一份可编辑 YAML 用例,后续可直接运行。 + +### 9.2 为什么不能只记录坐标 + +只记录坐标虽然最容易实现,但稳定性最差: + +- 不同手机分辨率、状态栏高度、导航栏模式会导致坐标偏移。 +- APP 页面布局变化后,坐标仍然能点下去,但可能点错位置。 +- 弹窗、键盘、网络慢加载会让固定坐标回放失败。 + +因此每个录制步骤建议保存“多定位策略”: + +```yaml +- name: 点击登录按钮 + action: tap + target: + preferred: poco + poco: + text: 登录 + name: com.example:id/login_button + image: + path: images/step_003_login_button.png + threshold: 0.85 + coord: + x: 524 + y: 1680 + screenWidth: 1080 + screenHeight: 2400 + waitAfterMs: 500 +``` + +回放时优先使用 Poco 元素定位;找不到元素时用图片识别;图片也找不到时才按比例坐标点击。 + +### 9.3 录制实现方案 + +首选实现路径: + +```text +Java UI 点击“开始录制” + -> Python recorder.py 连接 Android 设备 + -> 周期性读取屏幕截图和 UI 树 + -> 用户在真实手机上操作 + -> recorder 捕获操作差异/辅助确认 + -> 生成 recorded-flow.yaml +``` + +Android 上直接无侵入捕获所有触摸事件有权限限制,因此录制有两种可落地模式: + +| 模式 | 实现方式 | 优点 | 缺点 | +|------|----------|------|------| +| 辅助录制模式 | Java/Python 显示手机镜像或截图,用户在插件界面点,插件记录点击 | 最容易落地,可准确记录点击坐标和截图 | 不是直接点手机屏幕 | +| 真机旁路录制 | 用户点真机,录制器通过截图/UI树变化推断步骤,必要时让用户确认 | 更贴近真实操作 | 自动推断难度更高 | + +建议 MVP 采用“辅助录制模式”:插件显示手机当前截图,用户点击截图上的位置,插件把点击转发到手机,并同时记录步骤。这样既能完成真实操作,又能准确生成脚本。 + +### 9.4 辅助录制交互 + +```text +1. 用户选择设备和 APP +2. 点击“开始录制” +3. 插件实时刷新手机截图 +4. 用户在截图上点击、滑动、输入 +5. 插件执行对应手机动作 +6. 插件记录动作、截图、UI树和定位候选 +7. 用户点击“停止录制” +8. 插件生成 YAML 用例 +9. 用户点击“试运行”校验录制流程 +``` + +### 9.5 录制事件模型 + +```json +{ + "index": 3, + "action": "tap", + "timestamp": 1783071000123, + "screen": { + "width": 1080, + "height": 2400 + }, + "point": { + "x": 524, + "y": 1680 + }, + "pocoCandidates": [ + { + "text": "登录", + "name": "com.example:id/login_button", + "bounds": [420, 1600, 660, 1740] + } + ], + "imageTemplate": "images/step_003_login_button.png", + "beforeScreenshot": "screenshots/003_before.png", + "afterScreenshot": "screenshots/003_after.png", + "waitAfterMs": 500 +} +``` + +### 9.6 Python 录制器能力 + +`recorder.py` 需要提供: + +- 获取当前截图。 +- 获取当前 Poco UI 树。 +- 接收 Java UI 传来的点击/滑动/输入事件。 +- 把屏幕坐标转换为设备坐标。 +- 执行动作并记录前后截图。 +- 根据点击位置匹配最近的 Poco 元素。 +- 裁剪点击区域图片,生成图片模板。 +- 输出 `recorded-flow.yaml`。 + +### 9.7 回放策略 + +回放每一步时按以下顺序执行: + +```text +1. 如果有 Poco 定位,先用 Poco 查找并点击 +2. 如果 Poco 失败,使用图片模板识别并点击 +3. 如果图片失败,使用按分辨率比例换算后的坐标点击 +4. 每步后等待 UI 稳定,必要时对比 afterScreenshot 或断言元素 +5. 任一步失败时截图并写入报告 +``` + +### 9.8 录制回放验收标准 + +- 能在插件界面显示当前手机截图。 +- 用户点击截图后,手机同步执行点击。 +- 插件能记录点击步骤并生成 YAML。 +- YAML 能再次运行并完成同样流程。 +- 至少支持点击、输入、滑动、返回。 +- 失败时能定位到具体步骤并展示截图。 + +## 10. Python 核心伪代码 + +```python +from airtest.core.api import * +from poco.drivers.android.uiautomation import AndroidUiautomationPoco + +def connect(device_uri): + auto_setup(__file__, devices=[device_uri]) + return AndroidUiautomationPoco(use_airtest_input=True, screenshot_each_action=False) + +def tap_target(poco, target): + if target["type"] == "poco": + node = find_poco(poco, target["query"]) + node.wait_for_appearance(timeout=target.get("timeout", 10)) + node.click() + elif target["type"] == "image": + touch(Template(target["path"], threshold=target.get("threshold", 0.85))) + elif target["type"] == "coord": + touch((target["x"], target["y"])) + +def input_target(poco, target, text): + node = find_poco(poco, target["query"]) + node.wait_for_appearance(timeout=target.get("timeout", 10)) + node.click() + text(text) +``` + +注意:实际实现里要避免函数名 `text` 与 Airtest API 冲突,可封装为 `airtest_text` 或显式 import。 + +## 11. 环境管理设计 + +### 11.1 Java 检查项 + +| 检查项 | 命令 | 失败提示 | +|--------|------|----------| +| Python | `python --version` | 未安装 Python 或未加入 PATH | +| pip | `python -m pip --version` | pip 不可用 | +| airtest | `python -c "import airtest"` | 请安装 airtest | +| pocoui | `python -c "import poco"` | 请安装 pocoui | +| adb | `adb devices` | ADB 不可用或设备未授权 | + +### 11.2 自动安装策略 + +首期建议提供“复制安装命令”,不自动安装: + +```text +python -m pip install -U airtest pocoui pyyaml +``` + +原因: + +- 公司内网、代理、镜像源差异大。 +- 自动安装可能污染用户全局 Python。 +- 后续可以加虚拟环境模式:`data/mobile-ui-auto/.venv`。 + +## 12. 报告设计 + +每次执行生成独立目录: + +```text +reports/20260703-153000/ + result.json + run.log + screenshots/ + 001-start.png + 002-login-fail.png + summary.html +``` + +`result.json`: + +```json +{ + "caseName": "login-smoke", + "device": "Android:///xxx", + "package": "com.example.app", + "success": false, + "startTime": "2026-07-03T15:30:00+08:00", + "endTime": "2026-07-03T15:30:18+08:00", + "steps": [ + { + "index": 1, + "name": "等待首页", + "status": "PASS", + "elapsedMs": 1200 + }, + { + "index": 2, + "name": "点击登录", + "status": "FAIL", + "message": "图片不存在: images/login_button.png", + "screenshot": "screenshots/002-login-fail.png" + } + ] +} +``` + +## 13. Java 服务接口草案 + +```java +public interface MobileAutomationService { + EnvironmentCheckResult checkEnvironment(); + + List listDevices(); + + List listCases(); + + AutomationRunHandle runCase(AutomationRunConfig config, AutomationCallback callback); + + void stopRun(String runId); +} +``` + +```java +public interface AutomationCallback { + void onLog(String level, String message); + + void onStepStart(int index, String name); + + void onStepPass(int index, long elapsedMs); + + void onStepFail(int index, String message, String screenshotPath); + + void onDone(AutomationRunResult result); +} +``` + +## 14. 进程控制设计 + +`PythonRunner` 负责启动、读取和终止 Python 进程: + +- 使用 `ProcessBuilder`,不要拼接 shell 字符串。 +- stdout/stderr 各自独立线程读取,避免进程阻塞。 +- 支持超时终止。 +- 点击停止时先发送中断标记,必要时 `destroyForcibly()`。 +- 每次运行生成 `runId`,UI 只接收当前 `runId` 的回调。 + +## 15. 异常和稳定性策略 + +| 场景 | 策略 | +|------|------| +| 设备未连接 | Java 层运行前阻断 | +| 设备未授权 | 提示手机确认 USB 调试授权 | +| APP 启动失败 | 记录 package/activity 和 ADB 输出 | +| 元素找不到 | 自动截图,失败步骤写入 result.json | +| 图片识别失败 | 提示阈值、模板路径、当前截图 | +| Python 依赖缺失 | 环境检查页给出安装命令 | +| 运行超时 | Java 层终止 Python 进程并标记 TIMEOUT | +| 用户停止 | 标记 CANCELED,不算失败 | + +## 16. MVP 迭代计划 + +### 16.1 第一阶段:跑通闭环 + +- 新增 `MobileUiAutomationPlugin` +- 新增 JavaFX 嵌入式窗口 +- 实现环境检查 +- 实现设备刷新 +- 实现选择 YAML 用例并运行 +- Python Runner 支持 `tap/input/wait/assert/screenshot` +- 生成 `result.json` 和截图 + +### 16.2 第二阶段:可用性增强 + +- 用例列表管理 +- 新建 YAML 模板 +- 图片模板目录管理 +- 失败报告 HTML +- 支持 `.py` 和 `.air` 直接运行 +- 支持停止运行 +- 支持辅助录制回放:在插件截图面板点击、输入、滑动并生成 YAML + +### 16.3 第三阶段:进阶能力 + +- 自动安装/管理 Python 虚拟环境 +- uiautomator2 兜底定位 +- 多设备并发 +- logcat 采集 +- APK 安装/卸载 +- 接入 CI 命令行模式 + +## 17. 命令行模式设计 + +插件实现 `getCommandName()`: + +```text +mobile-ui +``` + +命令示例: + +```text +java -jar workutils.jar mobile-ui run --device Android:///xxx --case data/mobile-ui-auto/cases/login-flow.yaml +java -jar workutils.jar mobile-ui devices +java -jar workutils.jar mobile-ui check-env +``` + +这方便后续接入 Jenkins、GitLab CI 或本地批处理。 + +## 18. 与现有项目的集成点 + +| 集成点 | 设计 | +|--------|------| +| 插件注册 | `@Component` + `implements Plugin` | +| 可视化入口 | 实现 `getVisualFactory()` | +| 配置目录 | 复用 `data/`,新增 `data/mobile-ui-auto/` | +| 报告目录 | 复用 `export/` 或插件私有 `reports/` | +| 日志展示 | 参考现有 GitSyncWindow 的实时日志模式 | +| 命令执行 | 参考 GitSyncPlugin 的 `ProcessBuilder` 和输出读取模式 | + +## 19. 风险和规避 + +| 风险 | 规避 | +|------|------| +| UI 测试脆弱 | 优先 Poco 元素定位,图片和坐标只兜底 | +| 录制坐标不稳定 | 录制时同时保存 Poco 候选、图片模板和比例坐标 | +| 手机分辨率差异 | 图片模板分设备维护,坐标按比例转换 | +| 输入法影响文本输入 | 支持 ADB 输入、剪贴板输入、Poco set_text 多策略 | +| 第三方依赖安装困难 | 先做环境检查和命令提示,后做虚拟环境 | +| Python 进程卡死 | Java 层超时和强制终止 | +| 报告不可读 | 每步结构化结果 + 失败截图 | + +## 20. 首期验收标准 + +- 能在 Java UI 中检测 Python、Airtest、Poco、ADB 环境。 +- 能刷新并选择一台 Android 设备。 +- 能启动指定 APP。 +- 能运行一个 YAML 流程:等待页面、点击、输入、断言。 +- 能实时显示运行日志。 +- 失败时能自动截图并展示失败原因。 +- 能生成 `result.json`。 +- 支持用户手动停止运行。 + +## 21. 参考资料 + +- Airtest 官方文档:https://airtest.readthedocs.io/en/latest/ +- Airtest GitHub:https://github.com/AirtestProject/Airtest +- Poco GitHub:https://github.com/AirtestProject/Poco diff --git a/src/main/java/com/example/workutils/biz/gitreview/GitReviewPlugin.java b/src/main/java/com/example/workutils/biz/gitreview/GitReviewPlugin.java new file mode 100644 index 0000000..1545d92 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/gitreview/GitReviewPlugin.java @@ -0,0 +1,119 @@ +package com.example.workutils.biz.gitreview; + +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; +import com.example.workutils.system.support.JfxBootstrap; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Scanner; +import java.util.concurrent.CountDownLatch; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GitReviewPlugin implements Plugin { + + private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() + .id("git-review") + .name("Git分支评审") + .version("1.0.0") + .description("拉取双分支、切到目标分支,并打开 IDEA 差异评审") + .order(35) + .icon("⇄") + .build(); + + private static final String[] MENU_OPTIONS = { + "1. 打开可视化窗口", + "0. 返回上级菜单" + }; + + private final GitReviewService service; + private final WorkUtilsProperties properties; + + @Override + public PluginDescriptor getDescriptor() { + return DESCRIPTOR; + } + + @Override + public String[] getMenuOptions() { + return MENU_OPTIONS; + } + + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private GitReviewWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) { + return embeddedRoot; + } + latch = new CountDownLatch(1); + window = new GitReviewWindow(service, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new GitReviewWindow(service, latch); + } + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { + latch.await(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + onClosed.run(); + }, "git-review-close").start(); + } + }; + } + + @Override + public String checkConfig() { + Path projectPaths = properties.getProjectPathsFilePath(); + if (!Files.exists(projectPaths)) { + return "项目路径配置文件不存在:\n " + projectPaths.toAbsolutePath() + + "\n请先在系统设置中配置需要评审的 Git 项目路径。"; + } + return null; + } + + @Override + public void handleChoice(int choice, Scanner scanner) throws Exception { + if (choice == 1) { + System.out.println("正在打开 Git 分支评审窗口..."); + JfxBootstrap.await(); + if (JfxBootstrap.isFailed()) { + System.err.println("JavaFX 初始化失败"); + return; + } + CountDownLatch closeLatch = new CountDownLatch(1); + javafx.application.Platform.runLater(() -> new GitReviewWindow(service, closeLatch).show()); + closeLatch.await(); + System.out.println("Git 分支评审窗口已关闭"); + return; + } + printInvalidOption(); + } +} diff --git a/src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java b/src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java new file mode 100644 index 0000000..4cd2b19 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/gitreview/GitReviewService.java @@ -0,0 +1,693 @@ +package com.example.workutils.biz.gitreview; + +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.FileOperationHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +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.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GitReviewService { + + private static final long CMD_TIMEOUT_SECONDS = 120; + private static final int HISTORY_LIMIT = 50; + private static final DateTimeFormatter HISTORY_TIME_FORMAT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final FileOperationHelper fileOperationHelper; + private final WorkUtilsProperties properties; + + public List loadProjects() throws IOException { + List paths = fileOperationHelper.getProjectPathsWithStatus(); + List options = new ArrayList<>(); + for (FileOperationHelper.ProjectPathInfo path : paths) { + String name = fileOperationHelper.extractProjectName(path.getPath()); + options.add(new ProjectOption(name, path.getPath(), path.getRemoteBaseUrl())); + } + return options; + } + + public List loadHistory() throws IOException { + Path file = historyFile(); + if (!Files.exists(file)) { + return Collections.emptyList(); + } + List records = new ArrayList<>(); + for (String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + ReviewHistoryRecord record = parseHistoryLine(line); + if (record != null) { + records.add(record); + } + } + return records; + } + + public void saveHistory(ReviewHistoryRecord record) throws IOException { + if (record == null || record.getProjectName().isBlank() + || record.getProjectPath().isBlank() + || record.getSourceBranch().isBlank() + || record.getTargetBranch().isBlank()) { + return; + } + + ReviewHistoryRecord newRecord = record.withTimestamp(HISTORY_TIME_FORMAT.format(LocalDateTime.now())); + List records = new ArrayList<>(); + records.add(newRecord); + for (ReviewHistoryRecord existing : loadHistory()) { + if (!newRecord.sameReview(existing)) { + records.add(existing); + } + if (records.size() >= HISTORY_LIMIT) { + break; + } + } + writeHistory(records); + } + + public void clearHistory() throws IOException { + Files.deleteIfExists(historyFile()); + } + + public ReviewResult prepareReview(ReviewRequest request, Consumer logger) { + LogSink logs = new LogSink(logger); + ReviewResult result = new ReviewResult(); + result.logs = logs.lines; + + try { + Path projectPath = request.projectPath.toAbsolutePath().normalize(); + String projectName = projectPath.getFileName() == null + ? "project" + : projectPath.getFileName().toString(); + String targetBranch = normalizeBranchName(request.targetBranch, projectName); + String sourceBranch = normalizeBranchName(request.sourceBranch, projectName); + + logs.info("项目: " + projectPath); + logs.info("来源分支(A): " + sourceBranch); + logs.info("目标分支(B): " + targetBranch); + validateRepository(projectPath); + + if (hasLocalChanges(projectPath)) { + result.ok = false; + logs.error("当前工作区存在未提交改动,已停止。请先手工提交、stash 或清理后重试。"); + return result; + } + + fetchBranch(projectPath, targetBranch, logs); + fetchBranch(projectPath, sourceBranch, logs); + logs.info("将切换到 B 目标分支,不切换到 A 来源分支。"); + checkoutAndUpdateTarget(projectPath, targetBranch, logs); + result.currentBranch = targetBranch; + + String targetRef = resolveCompareRef(projectPath, request.targetCommit, targetBranch, logs, "目标 commit"); + String sourceRef = resolveCompareRef(projectPath, request.sourceCommit, "origin/" + sourceBranch, logs, "来源 commit"); + logs.info("对比范围: " + targetRef + ".." + sourceRef); + + result.changedFiles = diffChangedFiles(projectPath, targetRef, sourceRef, logs); + logs.info("差异文件数: " + result.changedFiles.size()); + + if (request.openIdeaProject) { + if (startIdea(request.ideaCommand, Collections.singletonList(projectPath.toString()), logs)) { + logs.info("已尝试打开 IDEA 项目窗口。"); + logs.info("IDEA 未提供稳定命令行入口直达 Git Compare Branches;已由界面复制 A 来源分支到剪贴板。"); + } else { + logs.warn("未能自动打开 IDEA 项目,请检查 IDEA 命令配置。"); + } + } + + result.ok = true; + return result; + } catch (Exception e) { + result.ok = false; + logs.error(e.getMessage()); + log.warn("Git review failed", e); + return result; + } + } + + private void validateRepository(Path projectPath) { + if (!Files.isDirectory(projectPath)) { + throw new IllegalStateException("项目目录不存在: " + projectPath); + } + if (!Files.exists(projectPath.resolve(".git"))) { + throw new IllegalStateException("该目录不是 Git 仓库: " + projectPath); + } + } + + private boolean hasLocalChanges(Path projectPath) { + CmdResult status = run(projectPath, "git", "status", "--porcelain"); + if (!status.ok) { + throw new IllegalStateException("检查工作区状态失败: " + status.error); + } + return !status.output.trim().isEmpty(); + } + + private void fetchBranch(Path projectPath, String branch, LogSink logs) { + logs.info("fetch origin " + branch); + runRequired(projectPath, logs, "git", "fetch", "--prune", "origin", + "+refs/heads/" + branch + ":refs/remotes/origin/" + branch); + } + + private void checkoutAndUpdateTarget(Path projectPath, String targetBranch, LogSink logs) { + if (localBranchExists(projectPath, targetBranch)) { + logs.info("切换到目标分支: " + targetBranch); + runRequired(projectPath, logs, "git", "checkout", targetBranch); + } else { + logs.info("创建本地目标分支并跟踪 origin/" + targetBranch); + runRequired(projectPath, logs, "git", "checkout", "-b", targetBranch, "--track", "origin/" + targetBranch); + } + logs.info("pull --ff-only origin " + targetBranch); + runRequired(projectPath, logs, "git", "pull", "--ff-only", "origin", targetBranch); + } + + private boolean localBranchExists(Path projectPath, String branch) { + return run(projectPath, "git", "rev-parse", "--verify", "--quiet", "refs/heads/" + branch).ok; + } + + private String resolveCompareRef(Path projectPath, String commit, String fallbackRef, + LogSink logs, String label) { + String cleanedCommit = safeTrim(commit); + if (!cleanedCommit.isEmpty()) { + if (run(projectPath, "git", "rev-parse", "--verify", "--quiet", + cleanedCommit + "^{commit}").ok) { + return cleanedCommit; + } + logs.warn(label + " 不存在或未拉取到本地,改用 " + fallbackRef); + } + if (!run(projectPath, "git", "rev-parse", "--verify", "--quiet", fallbackRef + "^{commit}").ok) { + throw new IllegalStateException("无法解析对比引用: " + fallbackRef); + } + return fallbackRef; + } + + private List diffChangedFiles(Path projectPath, String targetRef, String sourceRef, LogSink logs) { + CmdResult diff = runRequired(projectPath, logs, "git", "diff", "--name-status", targetRef + ".." + sourceRef); + List files = new ArrayList<>(); + for (String line : diff.output.split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isEmpty()) { + continue; + } + String[] parts = trimmed.split("\\t"); + if (parts.length == 1) { + files.add(new ChangedFile("", parts[0], "")); + } else if (parts.length == 2) { + files.add(new ChangedFile(parts[0], parts[1], "")); + } else { + files.add(new ChangedFile(parts[0], parts[1], parts[2])); + } + } + return files; + } + + private Path dataDirectory() { + Path config = properties.getWorkutilsConfigFilePath(); + Path parent = config.getParent(); + if (parent != null) { + return parent; + } + return Paths.get(System.getProperty("user.dir")).resolve("data"); + } + + private Path historyFile() { + return dataDirectory().resolve("git-review-history.tsv"); + } + + private void writeHistory(List records) throws IOException { + Path file = historyFile(); + Files.createDirectories(file.getParent()); + List lines = new ArrayList<>(); + for (ReviewHistoryRecord record : records) { + lines.add(toHistoryLine(record)); + } + Files.write(file, lines, StandardCharsets.UTF_8, + StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + } + + private String toHistoryLine(ReviewHistoryRecord record) { + return String.join("\t", + encode(record.getTimestamp()), + encode(record.getProjectName()), + encode(record.getProjectPath()), + encode(record.getSourceBranch()), + encode(record.getSourceCommit()), + encode(record.getTargetBranch()), + encode(record.getTargetCommit()), + encode(record.getRawText()), + encode(record.getIdeaCommand()) + ); + } + + private ReviewHistoryRecord parseHistoryLine(String line) { + if (line == null || line.isBlank()) { + return null; + } + String[] parts = line.split("\\t", -1); + if (parts.length < 7) { + return null; + } + return new ReviewHistoryRecord( + decode(parts, 0), + decode(parts, 1), + decode(parts, 2), + decode(parts, 3), + decode(parts, 4), + decode(parts, 5), + decode(parts, 6), + decode(parts, 7), + decode(parts, 8) + ); + } + + private String encode(String value) { + String safe = value == null ? "" : value; + return Base64.getEncoder().encodeToString(safe.getBytes(StandardCharsets.UTF_8)); + } + + private String decode(String[] values, int index) { + if (index >= values.length || values[index] == null || values[index].isEmpty()) { + return ""; + } + try { + return new String(Base64.getDecoder().decode(values[index]), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return ""; + } + } + + private boolean startIdea(String ideaCommand, List args, LogSink logs) { + List configured = splitCommandLine(ideaCommand); + List candidates = configured.isEmpty() + ? Arrays.asList("idea64.exe", "idea.bat", "idea") + : Collections.singletonList(String.join(" ", configured)); + + for (String candidate : candidates) { + List command = configured.isEmpty() + ? new ArrayList<>(Collections.singletonList(candidate)) + : new ArrayList<>(configured); + command.addAll(args); + if (startProcess(command)) { + return true; + } + } + logs.warn("IDEA 命令尝试失败: " + candidates); + return false; + } + + private boolean startProcess(List command) { + if (command.isEmpty()) { + return false; + } + List actual = new ArrayList<>(command); + String first = actual.get(0).toLowerCase(Locale.ROOT); + if (first.endsWith(".bat") || first.endsWith(".cmd")) { + actual.add(0, "/c"); + actual.add(0, "cmd.exe"); + } + try { + new ProcessBuilder(actual).start(); + return true; + } catch (IOException e) { + log.debug("Start process failed: {}", actual, e); + return false; + } + } + + private CmdResult runRequired(Path workingDir, LogSink logs, String... command) { + CmdResult result = run(workingDir, command); + if (!result.ok) { + throw new IllegalStateException(String.join(" ", command) + " 失败: " + result.error); + } + if (!result.output.isBlank()) { + logs.debug(result.output); + } + return result; + } + + private CmdResult run(Path workingDir, String... command) { + CmdResult result = new CmdResult(); + Process process = null; + Thread readerThread = null; + StringBuilder output = new StringBuilder(); + try { + ProcessBuilder builder = new ProcessBuilder(command); + builder.directory(workingDir.toFile()); + builder.redirectErrorStream(true); + process = builder.start(); + Process finalProcess = process; + readerThread = new Thread(() -> { + try (BufferedReader reader = new BufferedReader(new InputStreamReader( + finalProcess.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append(System.lineSeparator()); + } + } catch (IOException ignored) { + } + }, "git-review-reader"); + readerThread.setDaemon(true); + readerThread.start(); + + boolean finished = process.waitFor(CMD_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + result.ok = false; + result.error = "命令超时(" + CMD_TIMEOUT_SECONDS + "s)"; + return result; + } + readerThread.join(5_000); + result.exitCode = process.exitValue(); + result.output = output.toString().trim(); + result.ok = result.exitCode == 0; + if (!result.ok) { + result.error = result.output.isEmpty() + ? "exit code " + result.exitCode + : "exit code " + result.exitCode + ": " + result.output; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + result.ok = false; + result.error = "命令被中断"; + } catch (Exception e) { + result.ok = false; + result.error = e.getMessage(); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + if (readerThread != null && readerThread.isAlive()) { + readerThread.interrupt(); + } + } + return result; + } + + private String normalizeBranchName(String branch, String projectName) { + String value = safeTrim(branch); + String prefix = projectName + "/"; + if (value.startsWith(prefix)) { + value = value.substring(prefix.length()); + } + if (value.startsWith("origin/")) { + value = value.substring("origin/".length()); + } + if (value.isEmpty()) { + throw new IllegalArgumentException("分支名不能为空"); + } + return value; + } + + private String safeTrim(String value) { + return value == null ? "" : value.trim(); + } + + private List splitCommandLine(String commandLine) { + if (commandLine == null || commandLine.trim().isEmpty()) { + return Collections.emptyList(); + } + List parts = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + boolean quoted = false; + for (int i = 0; i < commandLine.length(); i++) { + char ch = commandLine.charAt(i); + if (ch == '"') { + quoted = !quoted; + continue; + } + if (Character.isWhitespace(ch) && !quoted) { + if (current.length() > 0) { + parts.add(current.toString()); + current.setLength(0); + } + continue; + } + current.append(ch); + } + if (current.length() > 0) { + parts.add(current.toString()); + } + return parts; + } + + public static class ProjectOption { + private final String name; + private final String path; + private final String remoteUrl; + + public ProjectOption(String name, String path, String remoteUrl) { + this.name = name; + this.path = path; + this.remoteUrl = remoteUrl; + } + + public String getName() { + return name; + } + + public String getPath() { + return path; + } + + public String getRemoteUrl() { + return remoteUrl; + } + + @Override + public String toString() { + return name; + } + } + + public static class ReviewHistoryRecord { + private final String timestamp; + private final String projectName; + private final String projectPath; + private final String sourceBranch; + private final String sourceCommit; + private final String targetBranch; + private final String targetCommit; + private final String rawText; + private final String ideaCommand; + + public ReviewHistoryRecord(String timestamp, String projectName, String projectPath, + String sourceBranch, String sourceCommit, + String targetBranch, String targetCommit, + String rawText, String ideaCommand) { + this.timestamp = safe(timestamp); + this.projectName = safe(projectName); + this.projectPath = safe(projectPath); + this.sourceBranch = safe(sourceBranch); + this.sourceCommit = safe(sourceCommit); + this.targetBranch = safe(targetBranch); + this.targetCommit = safe(targetCommit); + this.rawText = safe(rawText); + this.ideaCommand = safe(ideaCommand); + } + + private ReviewHistoryRecord withTimestamp(String timestamp) { + return new ReviewHistoryRecord(timestamp, projectName, projectPath, + sourceBranch, sourceCommit, targetBranch, targetCommit, rawText, ideaCommand); + } + + private boolean sameReview(ReviewHistoryRecord other) { + if (other == null) { + return false; + } + return projectName.equals(other.projectName) + && projectPath.equals(other.projectPath) + && sourceBranch.equals(other.sourceBranch) + && sourceCommit.equals(other.sourceCommit) + && targetBranch.equals(other.targetBranch) + && targetCommit.equals(other.targetCommit); + } + + private static String safe(String value) { + return value == null ? "" : value; + } + + public String getTimestamp() { + return timestamp; + } + + public String getDisplayTime() { + return timestamp; + } + + public String getProjectName() { + return projectName; + } + + public String getProjectPath() { + return projectPath; + } + + public String getSourceBranch() { + return sourceBranch; + } + + public String getSourceCommit() { + return sourceCommit; + } + + public String getTargetBranch() { + return targetBranch; + } + + public String getTargetCommit() { + return targetCommit; + } + + public String getBranchSummary() { + return sourceBranch + " -> " + targetBranch; + } + + public String getRawText() { + return rawText; + } + + public String getIdeaCommand() { + return ideaCommand; + } + } + + public static class ReviewRequest { + private final Path projectPath; + private final String targetBranch; + private final String sourceBranch; + private final String targetCommit; + private final String sourceCommit; + private final String ideaCommand; + private final boolean openIdeaProject; + + public ReviewRequest(Path projectPath, String targetBranch, String sourceBranch, + String targetCommit, String sourceCommit, String ideaCommand, + boolean openIdeaProject) { + this.projectPath = projectPath; + this.targetBranch = targetBranch; + this.sourceBranch = sourceBranch; + this.targetCommit = targetCommit; + this.sourceCommit = sourceCommit; + this.ideaCommand = ideaCommand; + this.openIdeaProject = openIdeaProject; + } + } + + public static class ReviewResult { + private boolean ok; + private List logs = Collections.emptyList(); + private List changedFiles = Collections.emptyList(); + private String currentBranch; + private Path targetWorktree; + private Path sourceWorktree; + + public boolean isOk() { + return ok; + } + + public List getLogs() { + return logs; + } + + public List getChangedFiles() { + return changedFiles; + } + + public String getCurrentBranch() { + return currentBranch; + } + + public Path getTargetWorktree() { + return targetWorktree; + } + + public Path getSourceWorktree() { + return sourceWorktree; + } + } + + public static class ChangedFile { + private final String status; + private final String path; + private final String newPath; + + public ChangedFile(String status, String path, String newPath) { + this.status = status; + this.path = path; + this.newPath = newPath; + } + + public String getStatus() { + return status; + } + + public String getPath() { + return path; + } + + public String getNewPath() { + return newPath; + } + + public String getDisplayPath() { + return newPath == null || newPath.isEmpty() ? path : path + " -> " + newPath; + } + } + + private static class CmdResult { + private boolean ok; + private int exitCode; + private String output = ""; + private String error = ""; + } + + private static class LogSink { + private final Consumer consumer; + private final List lines = new ArrayList<>(); + + private LogSink(Consumer consumer) { + this.consumer = consumer; + } + + private void info(String text) { + add("[INFO] " + text); + } + + private void warn(String text) { + add("[WARN] " + text); + } + + private void error(String text) { + add("[ERROR] " + text); + } + + private void debug(String text) { + add("[GIT] " + text); + } + + private void add(String text) { + lines.add(text); + if (consumer != null) { + consumer.accept(text); + } + } + } +} diff --git a/src/main/java/com/example/workutils/biz/gitreview/GitReviewWindow.java b/src/main/java/com/example/workutils/biz/gitreview/GitReviewWindow.java new file mode 100644 index 0000000..ad038da --- /dev/null +++ b/src/main/java/com/example/workutils/biz/gitreview/GitReviewWindow.java @@ -0,0 +1,857 @@ +package com.example.workutils.biz.gitreview; + +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.concurrent.Task; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Parent; +import javafx.scene.Scene; +import javafx.scene.control.Alert; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.Tab; +import javafx.scene.control.TabPane; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableRow; +import javafx.scene.control.TableView; +import javafx.scene.control.TextArea; +import javafx.scene.control.TextField; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.input.Clipboard; +import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.ColumnConstraints; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import javafx.stage.FileChooser; +import javafx.stage.Stage; + +import java.io.File; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class GitReviewWindow { + + private static final String DEFAULT_PROJECT_NAME = "cf-batch-creditpayment"; + private static final String DEFAULT_SOURCE_BRANCH = "TASK-1156339215001346320-1156339215001350454"; + private static final String DEFAULT_TARGET_BRANCH = "DEV-1156339215001346320"; + private static final Pattern COMMIT_ONLY_PATTERN = + Pattern.compile("(?i)^\\(?\\s*([0-9a-f]{6,40})\\s*\\)?$"); + private static final Pattern COMMIT_IN_PARENS_PATTERN = + Pattern.compile("(?i)^.*\\(\\s*([0-9a-f]{6,40})\\s*\\).*$"); + + private final GitReviewService service; + private final CountDownLatch closeLatch; + private final Stage stage; + private final List projects = new ArrayList<>(); + private final ObservableList historyRows = + FXCollections.observableArrayList(); + + private TextArea reviewTextArea; + private Label projectValueLabel; + private Label sourceBranchValueLabel; + private Label targetBranchValueLabel; + private TextField ideaCommandField; + private CheckBox openIdeaProjectCheck; + private Button runButton; + private Button historyOpenButton; + private TableView historyTable; + private TextArea logArea; + private Label statusLabel; + + private String currentProjectName = DEFAULT_PROJECT_NAME; + private String currentProjectPath = ""; + private String currentSourceBranch = DEFAULT_SOURCE_BRANCH; + private String currentTargetBranch = DEFAULT_TARGET_BRANCH; + private String currentRawText = ""; + private boolean applyingReviewText; + private boolean reviewRunning; + + public GitReviewWindow(GitReviewService service, CountDownLatch closeLatch) { + this.service = service; + this.closeLatch = closeLatch; + this.stage = new Stage(); + buildUI(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { + return stage; + } + + private void buildUI() { + stage.setTitle("Git 分支评审"); + stage.setWidth(1280); + stage.setHeight(780); + stage.setMinWidth(1120); + stage.setMinHeight(640); + stage.setOnCloseRequest(e -> closeLatch.countDown()); + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(Theme.S5)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + VBox top = new VBox(Theme.S2); + Label title = new Label("Git 分支评审"); + title.setFont(Theme.heading(24)); + title.setTextFill(Theme.TEXT_PRIMARY); + + Label desc = new Label("粘贴评审信息后识别为 A 来源分支 -> B 目标分支,执行时本地切换到 B。"); + desc.setFont(Theme.base(13)); + desc.setTextFill(Theme.TEXT_SECONDARY); + + top.getChildren().addAll(title, desc); + root.setTop(top); + + TabPane tabs = new TabPane(); + tabs.setTabClosingPolicy(TabPane.TabClosingPolicy.UNAVAILABLE); + tabs.getTabs().addAll(buildWorkflowTab(), buildHistoryTab()); + BorderPane.setMargin(tabs, new Insets(Theme.S4, 0, Theme.S3, 0)); + root.setCenter(tabs); + + statusLabel = new Label("就绪"); + statusLabel.setFont(Theme.base(13)); + statusLabel.setTextFill(Theme.TEXT_SECONDARY); + root.setBottom(statusLabel); + + Scene scene = new Scene(root); + Theme.applyCustomCss(scene); + stage.setScene(scene); + + loadProjects(); + refreshHistory(); + updateResultLabels(); + } + + private Tab buildWorkflowTab() { + Tab tab = new Tab("当前评审"); + tab.setContent(buildWorkflowPane()); + return tab; + } + + private Tab buildHistoryTab() { + Tab tab = new Tab("历史记录"); + tab.setContent(buildHistoryPane()); + return tab; + } + + private Parent buildWorkflowPane() { + VBox box = new VBox(Theme.S4); + Parent logPane = buildLogPane(); + VBox.setVgrow(logPane, Priority.ALWAYS); + box.getChildren().addAll(buildRecognizePane(), buildResultPane(), buildActions(), logPane); + return box; + } + + private Parent buildRecognizePane() { + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4)); + box.setStyle(cardStyle()); + + HBox header = new HBox(Theme.S2); + header.setAlignment(Pos.CENTER_LEFT); + Label label = new Label("粘贴评审信息"); + label.setFont(Theme.heading(14)); + label.setTextFill(Theme.TEXT_PRIMARY); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + Button clipboardButton = StyledButton.secondary("从剪贴板识别", Theme.base(12)); + clipboardButton.setOnAction(e -> fillFromClipboard()); + Button recognizeButton = StyledButton.secondary("识别填充", Theme.base(12)); + recognizeButton.setOnAction(e -> fillFromReviewText(true)); + Button clearButton = StyledButton.secondary("清空", Theme.base(12)); + clearButton.setOnAction(e -> reviewTextArea.clear()); + header.getChildren().addAll(label, spacer, clipboardButton, recognizeButton, clearButton); + + reviewTextArea = new TextArea(); + reviewTextArea.setFont(Theme.mono(12)); + reviewTextArea.setPrefRowCount(4); + reviewTextArea.setMinHeight(100); + reviewTextArea.setPromptText("cf-batch-creditpayment/TASK-... (A 来源分支)\n" + + "DEV-... (B 目标分支)"); + reviewTextArea.getStyleClass().add(Theme.CSS_TEXT_AREA); + reviewTextArea.textProperty().addListener((obs, oldValue, newValue) -> fillFromReviewText(false)); + + box.getChildren().addAll(header, reviewTextArea); + return box; + } + + private Parent buildResultPane() { + GridPane form = new GridPane(); + form.setHgap(Theme.S3); + form.setVgap(Theme.S3); + form.setPadding(new Insets(Theme.S4)); + form.setStyle(cardStyle()); + + ColumnConstraints labelCol = new ColumnConstraints(); + labelCol.setMinWidth(92); + labelCol.setPrefWidth(92); + ColumnConstraints inputCol = new ColumnConstraints(); + inputCol.setHgrow(Priority.ALWAYS); + ColumnConstraints rightLabelCol = new ColumnConstraints(); + rightLabelCol.setMinWidth(92); + rightLabelCol.setPrefWidth(92); + ColumnConstraints rightInputCol = new ColumnConstraints(); + rightInputCol.setHgrow(Priority.ALWAYS); + form.getColumnConstraints().addAll(labelCol, inputCol, rightLabelCol, rightInputCol); + + projectValueLabel = valueLabel(); + sourceBranchValueLabel = valueLabel(); + targetBranchValueLabel = valueLabel(); + ideaCommandField = textField("idea64.exe"); + + HBox ideaBox = new HBox(Theme.S2, ideaCommandField, smallButton("选择...", this::chooseIdeaCommand)); + HBox.setHgrow(ideaCommandField, Priority.ALWAYS); + + form.add(label("项目"), 0, 0); + form.add(projectValueLabel, 1, 0, 3, 1); + form.add(label("A 来源分支"), 0, 1); + form.add(sourceBranchValueLabel, 1, 1); + form.add(label("B 目标分支"), 2, 1); + form.add(targetBranchValueLabel, 3, 1); + form.add(label("IDEA命令"), 0, 2); + form.add(ideaBox, 1, 2, 3, 1); + + openIdeaProjectCheck = new CheckBox("打开 IDEA 项目"); + openIdeaProjectCheck.setSelected(true); + openIdeaProjectCheck.setFont(Theme.base(13)); + + HBox checks = new HBox(Theme.S4, openIdeaProjectCheck); + checks.setAlignment(Pos.CENTER_LEFT); + form.add(label("选项"), 0, 3); + form.add(checks, 1, 3, 3, 1); + + return form; + } + + private Parent buildActions() { + HBox actions = new HBox(Theme.S2); + actions.setAlignment(Pos.CENTER_LEFT); + + Button refreshButton = StyledButton.secondary("刷新项目/历史", Theme.base(13)); + refreshButton.setOnAction(e -> { + loadProjects(); + refreshHistory(); + }); + + runButton = StyledButton.primary("准备评审", Theme.bold(14)); + runButton.setOnAction(e -> startReview()); + + Button swapButton = StyledButton.secondary("交换来源/目标", Theme.base(13)); + swapButton.setOnAction(e -> swapBranches()); + + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + Label hint = new Label("识别结果只读展示;历史记录可双击切换。"); + hint.setFont(Theme.base(12)); + hint.setTextFill(Theme.TEXT_MUTED); + + actions.getChildren().addAll(refreshButton, runButton, swapButton, spacer, hint); + return actions; + } + + private Parent buildHistoryPane() { + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4, 0, 0, 0)); + + HBox header = new HBox(Theme.S2); + header.setAlignment(Pos.CENTER_LEFT); + Label title = new Label("历史评审记录"); + title.setFont(Theme.heading(15)); + title.setTextFill(Theme.TEXT_PRIMARY); + Region spacer = new Region(); + HBox.setHgrow(spacer, Priority.ALWAYS); + + historyOpenButton = StyledButton.primary("打开评审", Theme.base(12)); + historyOpenButton.setOnAction(e -> { + if (applySelectedHistory()) { + startReview(); + } + }); + Button clearButton = StyledButton.secondary("清空", Theme.base(12)); + clearButton.setOnAction(e -> clearHistory()); + header.getChildren().addAll(title, spacer, historyOpenButton, clearButton); + + historyTable = new TableView<>(); + historyTable.setItems(historyRows); + historyTable.setColumnResizePolicy(TableView.UNCONSTRAINED_RESIZE_POLICY); + historyTable.setStyle("-fx-background-color: white;" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-width: 1; -fx-border-radius: " + Theme.RADIUS_SM + ";"); + historyTable.setRowFactory(table -> { + TableRow row = new TableRow<>(); + row.setOnMouseClicked(event -> { + if (event.getClickCount() == 2 && !row.isEmpty()) { + applyHistory(row.getItem(), true); + } + }); + return row; + }); + + TableColumn timeCol = new TableColumn<>("时间"); + timeCol.setCellValueFactory(new PropertyValueFactory<>("displayTime")); + timeCol.setPrefWidth(170); + timeCol.setMinWidth(150); + timeCol.setMaxWidth(190); + + TableColumn projectCol = new TableColumn<>("项目名称"); + projectCol.setCellValueFactory(new PropertyValueFactory<>("projectName")); + projectCol.setPrefWidth(230); + projectCol.setMinWidth(160); + projectCol.setMaxWidth(320); + + TableColumn branchCol = new TableColumn<>("源分支 -> 目标分支"); + branchCol.setCellValueFactory(new PropertyValueFactory<>("branchSummary")); + branchCol.setPrefWidth(820); + branchCol.setMinWidth(520); + + historyTable.getColumns().addAll(timeCol, projectCol, branchCol); + VBox.setVgrow(historyTable, Priority.ALWAYS); + + box.getChildren().addAll(header, historyTable); + return box; + } + + private Parent buildLogPane() { + VBox box = new VBox(Theme.S2); + box.setPadding(new Insets(Theme.S4)); + box.setStyle(cardStyle()); + Label title = new Label("执行日志"); + title.setFont(Theme.heading(15)); + title.setTextFill(Theme.TEXT_PRIMARY); + + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setWrapText(false); + logArea.setFont(Theme.mono(12)); + logArea.getStyleClass().add(Theme.CSS_LOG_AREA); + VBox.setVgrow(logArea, Priority.ALWAYS); + + box.getChildren().addAll(title, logArea); + return box; + } + + private void fillFromClipboard() { + Clipboard clipboard = Clipboard.getSystemClipboard(); + if (!clipboard.hasString()) { + showWarning("剪贴板为空", "请先复制评审分支信息,再点击识别。"); + return; + } + reviewTextArea.setText(clipboard.getString()); + fillFromReviewText(true); + } + + private void fillFromReviewText(boolean showFeedback) { + if (applyingReviewText || reviewTextArea == null) { + return; + } + ParsedReview parsed = parseReviewText(reviewTextArea.getText()); + if (!parsed.hasRecognizedValue()) { + if (showFeedback) { + showWarning("无法识别", "请粘贴项目/来源分支、目标分支信息。括号行会被忽略。"); + } + return; + } + + applyParsedReview(parsed); + if (showFeedback) { + saveCurrentHistory(); + statusLabel.setText("已识别: " + displayCurrentSummary()); + } + } + + private void applyParsedReview(ParsedReview parsed) { + applyingReviewText = true; + try { + if (!isBlank(parsed.projectName)) { + currentProjectName = parsed.projectName; + if (!selectProjectByName(parsed.projectName)) { + currentProjectPath = ""; + } + } + if (!isBlank(parsed.sourceBranch)) { + currentSourceBranch = parsed.sourceBranch; + } + if (!isBlank(parsed.targetBranch)) { + currentTargetBranch = parsed.targetBranch; + } + currentRawText = reviewTextArea.getText(); + updateResultLabels(); + } finally { + applyingReviewText = false; + } + } + + private ParsedReview parseReviewText(String text) { + ParsedReview parsed = new ParsedReview(); + if (isBlank(text)) { + return parsed; + } + + List lines = new ArrayList<>(); + for (String rawLine : text.split("\\R")) { + String line = normalizeReviewLine(rawLine); + if (!line.isEmpty()) { + lines.add(line); + } + } + + for (String line : lines) { + String commit = extractCommit(line); + if (!isBlank(commit)) { + continue; + } + + BranchToken branch = parseBranchLine(line, parsed.projectName); + if (branch == null || isBlank(branch.branchName)) { + continue; + } + if (isBlank(parsed.sourceBranch)) { + parsed.sourceBranch = branch.branchName; + if (!isBlank(branch.projectName)) { + parsed.projectName = branch.projectName; + } + } else if (isBlank(parsed.targetBranch)) { + parsed.targetBranch = branch.branchName; + if (isBlank(parsed.projectName) && !isBlank(branch.projectName)) { + parsed.projectName = branch.projectName; + } + } + } + + return parsed; + } + + private String normalizeReviewLine(String rawLine) { + if (rawLine == null) { + return ""; + } + String line = rawLine.trim() + .replace(":", ":") + .replace("`", ""); + if (line.startsWith("- ") || line.startsWith("* ")) { + line = line.substring(2).trim(); + } + + int colon = line.indexOf(':'); + if (colon > 0 && colon <= 12) { + String label = line.substring(0, colon).toLowerCase(); + if (label.contains("项目") || label.contains("分支") + || label.contains("branch") || label.contains("project")) { + line = line.substring(colon + 1).trim(); + } + } + return line; + } + + private String extractCommit(String line) { + Matcher only = COMMIT_ONLY_PATTERN.matcher(line); + if (only.matches()) { + return only.group(1); + } + Matcher inParens = COMMIT_IN_PARENS_PATTERN.matcher(line); + if (inParens.matches()) { + return inParens.group(1); + } + return ""; + } + + private BranchToken parseBranchLine(String line, String knownProjectName) { + String value = line.trim(); + if (value.isEmpty()) { + return null; + } + + for (GitReviewService.ProjectOption option : projects) { + String prefix = option.getName() + "/"; + if (value.regionMatches(true, 0, prefix, 0, prefix.length())) { + return new BranchToken(option.getName(), normalizeBranchValue(value.substring(prefix.length()))); + } + } + + if (!isBlank(knownProjectName)) { + String prefix = knownProjectName + "/"; + if (value.regionMatches(true, 0, prefix, 0, prefix.length())) { + return new BranchToken(knownProjectName, normalizeBranchValue(value.substring(prefix.length()))); + } + } + + int slash = value.indexOf('/'); + if (slash > 0 && slash < value.length() - 1) { + String prefix = value.substring(0, slash); + String branch = value.substring(slash + 1); + if (looksLikeProjectPrefix(prefix)) { + return new BranchToken(prefix, normalizeBranchValue(branch)); + } + } + + return new BranchToken(null, normalizeBranchValue(value)); + } + + private boolean looksLikeProjectPrefix(String value) { + return value.matches("[A-Za-z0-9_.-]+") && value.contains("-"); + } + + private String normalizeBranchValue(String branch) { + String value = branch == null ? "" : branch.trim(); + if (value.startsWith("origin/")) { + value = value.substring("origin/".length()); + } + return value; + } + + private void loadProjects() { + try { + projects.clear(); + projects.addAll(service.loadProjects()); + if (!selectProjectByName(currentProjectName) && !projects.isEmpty()) { + GitReviewService.ProjectOption first = projects.get(0); + currentProjectName = first.getName(); + currentProjectPath = first.getPath(); + } + updateResultLabels(); + statusLabel.setText("已加载项目: " + projects.size() + " 个"); + fillFromReviewText(false); + } catch (Exception e) { + statusLabel.setText("加载项目失败: " + e.getMessage()); + showWarning("加载项目失败", e.getMessage()); + } + } + + private void refreshHistory() { + try { + historyRows.setAll(service.loadHistory()); + } catch (Exception e) { + appendLog("[WARN] 加载历史评审记录失败: " + e.getMessage()); + } + } + + private void saveCurrentHistory() { + if (!isCurrentReviewComplete()) { + return; + } + try { + service.saveHistory(new GitReviewService.ReviewHistoryRecord( + null, + currentProjectName, + currentProjectPath, + currentSourceBranch, + "", + currentTargetBranch, + "", + currentRawText, + ideaCommandField.getText() + )); + refreshHistory(); + } catch (Exception e) { + appendLog("[WARN] 保存历史评审记录失败: " + e.getMessage()); + } + } + + private boolean applySelectedHistory() { + GitReviewService.ReviewHistoryRecord selected = historyTable.getSelectionModel().getSelectedItem(); + if (selected == null) { + showWarning("未选择历史记录", "请先选择一条历史评审记录。"); + return false; + } + applyHistory(selected, true); + return true; + } + + private void applyHistory(GitReviewService.ReviewHistoryRecord record, boolean showStatus) { + currentProjectName = record.getProjectName(); + currentProjectPath = record.getProjectPath(); + currentSourceBranch = record.getSourceBranch(); + currentTargetBranch = record.getTargetBranch(); + currentRawText = record.getRawText(); + if (!isBlank(record.getIdeaCommand())) { + ideaCommandField.setText(record.getIdeaCommand()); + } + applyingReviewText = true; + try { + reviewTextArea.setText(isBlank(currentRawText) ? buildReviewTextFromCurrent() : currentRawText); + } finally { + applyingReviewText = false; + } + updateResultLabels(); + if (showStatus) { + statusLabel.setText("已切换到历史记录: " + displayCurrentSummary()); + } + } + + private void clearHistory() { + try { + service.clearHistory(); + refreshHistory(); + statusLabel.setText("历史评审记录已清空"); + } catch (Exception e) { + showWarning("清空历史失败", e.getMessage()); + } + } + + private void startReview() { + if (reviewRunning) { + return; + } + if (isBlank(currentProjectPath)) { + showWarning("缺少项目", "请粘贴带项目名前缀的评审信息,或从历史记录切换一个有效项目。"); + return; + } + if (isBlank(currentSourceBranch) || isBlank(currentTargetBranch)) { + showWarning("缺少分支", "请先识别 A 来源分支和 B 目标分支。"); + return; + } + + GitReviewService.ReviewRequest request = new GitReviewService.ReviewRequest( + Paths.get(currentProjectPath), + currentTargetBranch, + currentSourceBranch, + "", + "", + ideaCommandField.getText(), + openIdeaProjectCheck.isSelected() + ); + + setReviewRunning(true); + logArea.clear(); + copySourceBranchToClipboard(); + statusLabel.setText("正在准备评审..."); + saveCurrentHistory(); + + Task task = new Task<>() { + @Override + protected GitReviewService.ReviewResult call() { + return service.prepareReview(request, msg -> + Platform.runLater(() -> appendLog(msg))); + } + }; + + task.setOnSucceeded(e -> { + GitReviewService.ReviewResult result = task.getValue(); + setReviewRunning(false); + if (result.isOk()) { + saveCurrentHistory(); + statusLabel.setText("完成: 已切到 B 目标分支 " + result.getCurrentBranch() + + ",差异文件 " + result.getChangedFiles().size() + " 个"); + } else { + statusLabel.setText("执行失败,查看日志获取原因"); + } + }); + task.setOnFailed(e -> { + setReviewRunning(false); + Throwable error = task.getException(); + statusLabel.setText("执行异常: " + (error == null ? "未知错误" : error.getMessage())); + if (error != null) { + appendLog("[ERROR] " + error.getMessage()); + } + }); + + Thread worker = new Thread(task, "git-review-worker"); + worker.setDaemon(true); + worker.start(); + } + + private void setReviewRunning(boolean running) { + this.reviewRunning = running; + updateLoadingButton(runButton, running, "准备评审", "执行中..."); + updateLoadingButton(historyOpenButton, running, "打开评审", "执行中..."); + } + + private void copySourceBranchToClipboard() { + ClipboardContent content = new ClipboardContent(); + content.putString(currentSourceBranch); + Clipboard.getSystemClipboard().setContent(content); + appendLog("[INFO] 已复制 A 来源分支到剪贴板: " + currentSourceBranch); + appendLog("[INFO] IDEA 打开后可在 Git Compare Branch 中直接粘贴该分支名。"); + } + + private void updateLoadingButton(Button button, boolean loading, String idleText, String loadingText) { + if (button == null) { + return; + } + button.setDisable(loading); + button.setText(loading ? loadingText : idleText); + button.setGraphic(loading ? createLoadingIndicator() : null); + } + + private ProgressIndicator createLoadingIndicator() { + ProgressIndicator indicator = new ProgressIndicator(); + indicator.setPrefSize(14, 14); + indicator.setMinSize(14, 14); + indicator.setMaxSize(14, 14); + return indicator; + } + + private void appendLog(String message) { + if (logArea == null) { + return; + } + logArea.appendText(message + System.lineSeparator()); + logArea.setScrollTop(Double.MAX_VALUE); + } + + private void swapBranches() { + String branch = currentTargetBranch; + currentTargetBranch = currentSourceBranch; + currentSourceBranch = branch; + updateResultLabels(); + } + + private void chooseIdeaCommand() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("选择 IDEA 启动程序"); + chooser.getExtensionFilters().addAll( + new FileChooser.ExtensionFilter("IDEA / Scripts", "*.exe", "*.bat", "*.cmd"), + new FileChooser.ExtensionFilter("All Files", "*.*") + ); + File selected = chooser.showOpenDialog(stage); + if (selected != null) { + ideaCommandField.setText(selected.getAbsolutePath()); + } + } + + private boolean selectProjectByName(String projectName) { + if (isBlank(projectName)) { + return false; + } + for (GitReviewService.ProjectOption option : projects) { + if (option.getName().equalsIgnoreCase(projectName)) { + currentProjectName = option.getName(); + currentProjectPath = option.getPath(); + return true; + } + } + return false; + } + + private boolean isCurrentReviewComplete() { + return !isBlank(currentProjectName) + && !isBlank(currentProjectPath) + && !isBlank(currentSourceBranch) + && !isBlank(currentTargetBranch); + } + + private String displayCurrentSummary() { + return currentProjectName + " | A " + currentSourceBranch + " -> B " + currentTargetBranch; + } + + private String buildReviewTextFromCurrent() { + return currentProjectName + "/" + currentSourceBranch + System.lineSeparator() + + currentTargetBranch; + } + + private void updateResultLabels() { + if (projectValueLabel == null) { + return; + } + projectValueLabel.setText(valueOrDash(currentProjectName)); + sourceBranchValueLabel.setText(valueOrDash(currentSourceBranch)); + targetBranchValueLabel.setText(valueOrDash(currentTargetBranch)); + } + + private String valueOrDash(String value) { + return isBlank(value) ? "-" : value; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private String cardStyle() { + return "-fx-background-color: " + Theme.toWeb(Theme.BG_SURFACE) + ";" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-radius: " + Theme.RADIUS_MD + ";" + + "-fx-background-radius: " + Theme.RADIUS_MD + ";"; + } + + private TextField textField(String value) { + TextField field = new TextField(value); + field.setFont(Theme.base(13)); + field.getStyleClass().add(Theme.CSS_TEXT_FIELD); + field.setMaxWidth(Double.MAX_VALUE); + return field; + } + + private Label label(String text) { + Label label = new Label(text); + label.setFont(Theme.base(13)); + label.setTextFill(Theme.TEXT_PRIMARY); + return label; + } + + private Label valueLabel() { + Label label = new Label("-"); + label.setFont(Theme.base(13)); + label.setTextFill(Theme.TEXT_PRIMARY); + label.setMinHeight(32); + label.setMaxWidth(Double.MAX_VALUE); + label.setPadding(new Insets(6, 8, 6, 8)); + label.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_CARD) + ";" + + "-fx-border-color: " + Theme.toWeb(Theme.BORDER) + ";" + + "-fx-border-radius: " + Theme.RADIUS_SM + ";" + + "-fx-background-radius: " + Theme.RADIUS_SM + ";"); + return label; + } + + private Button smallButton(String text, Runnable action) { + Button button = StyledButton.secondary(text, Theme.base(12)); + button.setOnAction(e -> action.run()); + return button; + } + + private void showWarning(String header, String content) { + Alert alert = new Alert(Alert.AlertType.WARNING); + alert.setTitle("Git 分支评审"); + alert.setHeaderText(header); + alert.setContentText(content); + alert.showAndWait(); + } + + private static class ParsedReview { + private String projectName; + private String targetBranch; + private String sourceBranch; + + private boolean hasRecognizedValue() { + return !isEmpty(projectName) + || !isEmpty(targetBranch) + || !isEmpty(sourceBranch); + } + + private static boolean isEmpty(String value) { + return value == null || value.trim().isEmpty(); + } + } + + private static class BranchToken { + private final String projectName; + private final String branchName; + + private BranchToken(String projectName, String branchName) { + this.projectName = projectName; + this.branchName = branchName; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationPlugin.java b/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationPlugin.java new file mode 100644 index 0000000..3fdfe74 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationPlugin.java @@ -0,0 +1,135 @@ +package com.example.workutils.biz.mobileuiauto; + +import com.example.workutils.biz.mobileuiauto.service.MobileAutomationService; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.plugin.Plugin; +import com.example.workutils.system.plugin.PluginDescriptor; +import com.example.workutils.system.plugin.VisualPluginFactory; +import com.example.workutils.system.support.JfxBootstrap; +import javafx.application.Platform; +import javafx.scene.Parent; +import javafx.scene.layout.Pane; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Scanner; +import java.util.concurrent.CountDownLatch; + +@Component +@RequiredArgsConstructor +public class MobileUiAutomationPlugin implements Plugin { + + private static final PluginDescriptor DESCRIPTOR = PluginDescriptor.builder() + .id("mobile-ui-auto") + .name("手机UI自动化") + .version("1.0.0") + .description("基于 Python + Airtest + Poco + ADB 的手机 APP 流程自动化测试工具") + .order(80) + .icon("📱") + .build(); + + private static final String[] MENU_OPTIONS = { + "1. 打开手机UI自动化窗口", + "0. 返回上级菜单" + }; + + private final MobileAutomationService automationService; + private final WorkUtilsProperties properties; + + @Override + public PluginDescriptor getDescriptor() { + return DESCRIPTOR; + } + + @Override + public String[] getMenuOptions() { + return MENU_OPTIONS; + } + + @Override + public String getCommandName() { + return "mobile-ui"; + } + + @Override + public VisualPluginFactory getVisualFactory() { + return new VisualPluginFactory() { + private MobileUiAutomationWindow window; + private Parent embeddedRoot; + private CountDownLatch latch; + + @Override + public Parent getEmbeddedPane() { + if (embeddedRoot != null) { + return embeddedRoot; + } + latch = new CountDownLatch(1); + window = new MobileUiAutomationWindow(automationService, properties, latch); + embeddedRoot = window.getContentPane(); + window.getStage().getScene().setRoot(new Pane()); + return embeddedRoot; + } + + @Override + public void open(Runnable onClosed) { + if (window == null) { + latch = new CountDownLatch(1); + window = new MobileUiAutomationWindow(automationService, properties, latch); + } + if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) { + window.getStage().getScene().setRoot(embeddedRoot); + } + window.show(); + new Thread(() -> { + try { + latch.await(); + } catch (InterruptedException ignored) { + } + onClosed.run(); + }, "mobile-ui-auto-close").start(); + } + }; + } + + @Override + public void handleChoice(int choice, Scanner scanner) { + if (choice == 1) { + openWindow(); + } else { + printInvalidOption(); + } + } + + @Override + public void executeCommand(String... args) throws Exception { + if (args == null || args.length == 0 || "check-env".equalsIgnoreCase(args[0])) { + automationService.checkEnvironment().getItems().forEach(item -> + System.out.println((item.isOk() ? "[OK] " : "[FAIL] ") + item.getName() + " - " + item.getDetail())); + return; + } + if ("devices".equalsIgnoreCase(args[0])) { + automationService.listDevices().forEach(System.out::println); + return; + } + System.out.println("支持命令: mobile-ui check-env | mobile-ui devices。用例运行请使用可视化窗口。"); + } + + private void openWindow() { + try { + JfxBootstrap.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + CountDownLatch closeLatch = new CountDownLatch(1); + Platform.runLater(() -> { + MobileUiAutomationWindow window = new MobileUiAutomationWindow(automationService, properties, closeLatch); + window.show(); + }); + try { + closeLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationWindow.java b/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationWindow.java new file mode 100644 index 0000000..b4bf48d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/MobileUiAutomationWindow.java @@ -0,0 +1,549 @@ +package com.example.workutils.biz.mobileuiauto; + +import com.example.workutils.biz.mobileuiauto.service.MobileAutomationService; +import com.example.workutils.biz.mobileuiauto.service.model.*; +import com.example.workutils.system.config.WorkUtilsProperties; +import com.example.workutils.system.support.SystemOpenHelper; +import com.example.workutils.system.ui.StyledButton; +import com.example.workutils.system.ui.Theme; +import javafx.application.Platform; +import javafx.collections.FXCollections; +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.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.MouseButton; +import javafx.scene.layout.*; +import javafx.scene.paint.Color; +import javafx.stage.FileChooser; +import javafx.stage.Screen; +import javafx.stage.Stage; + +import java.awt.Desktop; +import java.io.File; +import java.net.URI; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.CountDownLatch; + +public class MobileUiAutomationWindow { + + private final MobileAutomationService service; + private final WorkUtilsProperties properties; + private final CountDownLatch closeLatch; + private final Stage stage = new Stage(); + + private ComboBox deviceCombo; + private TextField packageField; + private TextField activityField; + private ListView caseList; + private TextArea logArea; + private Label statusLabel; + private Button downloadEnvButton; + private Button initEnvButton; + private Button runButton; + private Button stopButton; + private AutomationRunHandle currentRun; + + private ImageView phoneImageView; + private Label recordStatusLabel; + private TextField recordInputField; + + public MobileUiAutomationWindow(MobileAutomationService service, WorkUtilsProperties properties, CountDownLatch closeLatch) { + this.service = service; + this.properties = properties; + this.closeLatch = closeLatch; + buildUI(); + refreshCases(); + } + + public void show() { + stage.show(); + } + + public Parent getContentPane() { + return stage.getScene().getRoot(); + } + + Stage getStage() { + return stage; + } + + private void buildUI() { + stage.setTitle("手机UI自动化"); + stage.setOnHidden(e -> closeLatch.countDown()); + + BorderPane root = new BorderPane(); + root.setPadding(new Insets(Theme.S3)); + root.setStyle("-fx-background-color: " + Theme.toWeb(Theme.BG_BODY) + ";"); + + root.setTop(buildTopBar()); + root.setCenter(buildTabs()); + root.setBottom(buildStatusBar()); + + Rectangle2D screen = Screen.getPrimary().getVisualBounds(); + Scene scene = new Scene(root, screen.getWidth() * 0.78, screen.getHeight() * 0.78); + Theme.applyCustomCss(scene); + stage.setScene(scene); + stage.setMinWidth(screen.getWidth() * 0.55); + stage.setMinHeight(screen.getHeight() * 0.55); + } + + private Parent buildTopBar() { + Label title = new Label("手机UI自动化"); + title.setFont(Theme.bold(properties.getFontH3())); + title.setTextFill(Theme.TEXT_PRIMARY); + + downloadEnvButton = StyledButton.secondary("下载Python3.11", Theme.base(properties.getFontButton())); + initEnvButton = StyledButton.primary("初始化环境", Theme.bold(properties.getFontButton())); + Button envButton = StyledButton.secondary("检查环境", Theme.base(properties.getFontButton())); + Button devicesButton = StyledButton.secondary("刷新设备", Theme.base(properties.getFontButton())); + Button casesButton = StyledButton.secondary("打开用例目录", Theme.base(properties.getFontButton())); + Button reportsButton = StyledButton.secondary("打开报告目录", Theme.base(properties.getFontButton())); + + downloadEnvButton.setOnAction(e -> openPythonDownload()); + initEnvButton.setOnAction(e -> initializeEnvironment()); + envButton.setOnAction(e -> checkEnvironment()); + devicesButton.setOnAction(e -> refreshDevices()); + casesButton.setOnAction(e -> openPath(service.getCasesDir())); + reportsButton.setOnAction(e -> openPath(service.getReportsDir())); + + deviceCombo = new ComboBox<>(); + deviceCombo.setPrefWidth(260); + deviceCombo.setPromptText("选择设备"); + + HBox bar = new HBox(Theme.S2, title, new Region(), deviceCombo, downloadEnvButton, initEnvButton, envButton, devicesButton, casesButton, reportsButton); + HBox.setHgrow(bar.getChildren().get(1), Priority.ALWAYS); + bar.setAlignment(Pos.CENTER_LEFT); + bar.setPadding(new Insets(0, 0, Theme.S3, 0)); + return bar; + } + + private Parent buildTabs() { + TabPane tabs = new TabPane(); + Tab runTab = new Tab("执行用例", buildRunPane()); + Tab recordTab = new Tab("辅助录制", buildRecordPane()); + runTab.setClosable(false); + recordTab.setClosable(false); + tabs.getTabs().addAll(runTab, recordTab); + return tabs; + } + + private Parent buildRunPane() { + packageField = new TextField(); + packageField.setPromptText("com.example.app"); + activityField = new TextField(); + activityField.setPromptText(".MainActivity,可选"); + + caseList = new ListView<>(); + caseList.setCellFactory(list -> new ListCell<>() { + @Override + protected void updateItem(Path item, boolean empty) { + super.updateItem(item, empty); + setText(empty || item == null ? null : item.getFileName().toString()); + } + }); + + Button refreshCasesButton = StyledButton.secondary("刷新用例", Theme.base(properties.getFontButton())); + Button importCaseButton = StyledButton.secondary("导入用例", Theme.base(properties.getFontButton())); + runButton = StyledButton.primary("运行", Theme.bold(properties.getFontButton())); + stopButton = StyledButton.danger("停止", Theme.base(properties.getFontButton())); + stopButton.setDisable(true); + + refreshCasesButton.setOnAction(e -> refreshCases()); + importCaseButton.setOnAction(e -> importCase()); + runButton.setOnAction(e -> runSelectedCase()); + stopButton.setOnAction(e -> stopCurrentRun()); + + GridPane form = new GridPane(); + form.setHgap(Theme.S2); + form.setVgap(Theme.S2); + form.add(new Label("Package"), 0, 0); + form.add(packageField, 1, 0); + form.add(new Label("Activity"), 0, 1); + form.add(activityField, 1, 1); + ColumnConstraints c0 = new ColumnConstraints(); + c0.setMinWidth(80); + ColumnConstraints c1 = new ColumnConstraints(); + c1.setHgrow(Priority.ALWAYS); + form.getColumnConstraints().addAll(c0, c1); + + HBox actions = new HBox(Theme.S2, refreshCasesButton, importCaseButton, new Region(), runButton, stopButton); + HBox.setHgrow(actions.getChildren().get(2), Priority.ALWAYS); + actions.setAlignment(Pos.CENTER_LEFT); + + VBox left = new VBox(Theme.S2, form, actions, new Label("用例列表"), caseList); + VBox.setVgrow(caseList, Priority.ALWAYS); + left.setPadding(new Insets(Theme.S3)); + left.setPrefWidth(360); + + logArea = new TextArea(); + logArea.setEditable(false); + logArea.setWrapText(false); + logArea.setFont(Theme.mono(properties.getFontMono())); + logArea.getStyleClass().add(Theme.CSS_LOG_AREA); + + SplitPane split = new SplitPane(left, logArea); + split.setDividerPositions(0.35); + return split; + } + + private Parent buildRecordPane() { + phoneImageView = new ImageView(); + phoneImageView.setPreserveRatio(true); + phoneImageView.setFitWidth(420); + phoneImageView.setFitHeight(720); + phoneImageView.setStyle("-fx-background-color: #111827; -fx-border-color: " + Theme.toWeb(Theme.BORDER) + "; -fx-border-width: 1;"); + phoneImageView.setOnMouseClicked(event -> { + if (event.getButton() == MouseButton.PRIMARY) { + recordTap(event.getX(), event.getY()); + } + }); + + Button startButton = StyledButton.primary("开始录制", Theme.bold(properties.getFontButton())); + Button screenshotButton = StyledButton.secondary("刷新截图", Theme.base(properties.getFontButton())); + Button backButton = StyledButton.secondary("返回", Theme.base(properties.getFontButton())); + Button stopButton = StyledButton.success("停止并生成用例", Theme.base(properties.getFontButton())); + recordInputField = new TextField(); + recordInputField.setPromptText("输入文本后点击发送"); + Button inputButton = StyledButton.secondary("发送输入", Theme.base(properties.getFontButton())); + + startButton.setOnAction(e -> startRecording()); + screenshotButton.setOnAction(e -> refreshRecordScreenshot()); + backButton.setOnAction(e -> recordBack()); + inputButton.setOnAction(e -> recordInput()); + stopButton.setOnAction(e -> stopRecording()); + + recordStatusLabel = new Label("点击开始录制后,在手机截图上点击即可同步操作并记录步骤。"); + recordStatusLabel.setTextFill(Theme.TEXT_SECONDARY); + + HBox inputBox = new HBox(Theme.S2, recordInputField, inputButton); + HBox.setHgrow(recordInputField, Priority.ALWAYS); + VBox controls = new VBox(Theme.S2, startButton, screenshotButton, backButton, inputBox, stopButton, recordStatusLabel); + controls.setPadding(new Insets(Theme.S3)); + controls.setPrefWidth(330); + + BorderPane preview = new BorderPane(phoneImageView); + preview.setPadding(new Insets(Theme.S3)); + preview.setStyle("-fx-background-color: #f8fafc;"); + + HBox root = new HBox(Theme.S3, controls, preview); + HBox.setHgrow(preview, Priority.ALWAYS); + return root; + } + + private Parent buildStatusBar() { + statusLabel = new Label("就绪"); + statusLabel.setFont(Theme.base(properties.getFontSmall())); + statusLabel.setTextFill(Theme.TEXT_SECONDARY); + statusLabel.setPadding(new Insets(Theme.S2, 0, 0, 0)); + return statusLabel; + } + + private void checkEnvironment() { + appendLog("检查环境..."); + runBackground(() -> { + EnvironmentCheckResult result = service.checkEnvironment(); + Platform.runLater(() -> { + for (EnvironmentCheckResult.Item item : result.getItems()) { + appendLog((item.isOk() ? "[OK] " : "[FAIL] ") + item.getName() + " - " + item.getDetail()); + } + setStatus(result.isOk() ? "环境检查通过" : "环境检查存在问题", !result.isOk()); + }); + }); + } + + private void initializeEnvironment() { + appendLog("初始化项目环境..."); + setStatus("正在初始化项目环境,首次安装可能需要几分钟", false); + initEnvButton.setDisable(true); + runBackground(() -> { + try { + service.initializeProjectEnvironment(new AutomationCallback() { + @Override + public void onEvent(String type, String level, String message) { + Platform.runLater(() -> appendLog("[" + level + "] " + message)); + } + + @Override + public void onDone(AutomationRunResult result) { + } + }); + Platform.runLater(() -> { + appendLog("项目环境初始化完成"); + setStatus("项目环境初始化完成", false); + initEnvButton.setDisable(false); + checkEnvironment(); + }); + } catch (Exception e) { + Platform.runLater(() -> { + appendLog("[ERROR] 初始化环境失败: " + e.getMessage()); + setStatus("初始化环境失败: " + e.getMessage(), true); + initEnvButton.setDisable(false); + }); + } + }); + } + + private void openPythonDownload() { + String url = "https://www.python.org/ftp/python/3.11.9/python-3.11.9-amd64.exe"; + try { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + appendLog("[WARN] 无法自动打开浏览器,请手动访问: " + url); + setStatus("无法自动打开浏览器,下载地址已写入日志", true); + return; + } + Desktop.getDesktop().browse(URI.create(url)); + appendLog("[INFO] 已打开 Python 3.11 安装包下载地址: " + url); + appendLog("[INFO] 安装时建议勾选 Add python.exe to PATH,并保留 Python Launcher。安装完成后重启 WorkUtils,再点击“初始化环境”。"); + setStatus("已打开 Python 3.11 下载地址", false); + } catch (Exception e) { + appendLog("[ERROR] 打开下载地址失败: " + e.getMessage()); + appendLog("[INFO] 手动下载地址: " + url); + setStatus("打开下载地址失败,下载地址已写入日志", true); + } + } + + private void refreshDevices() { + runBackground(() -> { + try { + List devices = service.listDevices(); + Platform.runLater(() -> { + deviceCombo.setItems(FXCollections.observableArrayList(devices)); + if (!devices.isEmpty()) { + deviceCombo.getSelectionModel().select(0); + } + setStatus("设备数量: " + devices.size(), false); + }); + } catch (Exception e) { + Platform.runLater(() -> setStatus("刷新设备失败: " + e.getMessage(), true)); + } + }); + } + + private void refreshCases() { + if (caseList == null) { + return; + } + caseList.setItems(FXCollections.observableArrayList(service.listCases())); + } + + private void importCase() { + FileChooser chooser = new FileChooser(); + chooser.setTitle("导入自动化用例"); + chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter("Automation Cases", "*.yaml", "*.yml", "*.py", "*.air")); + File file = chooser.showOpenDialog(stage); + if (file == null) { + return; + } + try { + java.nio.file.Files.copy(file.toPath(), service.getCasesDir().resolve(file.getName()), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + refreshCases(); + setStatus("已导入用例: " + file.getName(), false); + } catch (Exception e) { + setStatus("导入失败: " + e.getMessage(), true); + } + } + + private void runSelectedCase() { + Path casePath = caseList.getSelectionModel().getSelectedItem(); + if (casePath == null) { + setStatus("请选择用例", true); + return; + } + AutomationRunConfig config = new AutomationRunConfig(); + DeviceInfo device = deviceCombo.getSelectionModel().getSelectedItem(); + config.setDeviceSerial(device != null ? device.getSerial() : ""); + config.setPackageName(packageField.getText().trim()); + config.setActivityName(activityField.getText().trim()); + config.setCasePath(casePath); + config.setTimeoutSeconds(20); + config.setScreenshotOnFail(true); + + runButton.setDisable(true); + stopButton.setDisable(false); + try { + currentRun = service.runCase(config, new AutomationCallback() { + @Override + public void onEvent(String type, String level, String message) { + Platform.runLater(() -> appendLog("[" + level + "] " + message)); + } + + @Override + public void onDone(AutomationRunResult result) { + Platform.runLater(() -> { + runButton.setDisable(false); + stopButton.setDisable(true); + appendLog(result.getMessage()); + setStatus(result.getMessage(), !result.isSuccess()); + }); + } + }); + } catch (Exception e) { + runButton.setDisable(false); + stopButton.setDisable(true); + setStatus("运行失败: " + e.getMessage(), true); + } + } + + private void stopCurrentRun() { + if (currentRun != null) { + service.stopRun(currentRun.getRunId()); + appendLog("已请求停止运行"); + stopButton.setDisable(true); + } + } + + private void startRecording() { + DeviceInfo device = deviceCombo.getSelectionModel().getSelectedItem(); + try { + service.startRecording(device != null ? device.getSerial() : "", packageField.getText().trim()); + setRecordStatus("录制已开始"); + refreshRecordScreenshot(); + } catch (Exception e) { + setRecordStatus("开始录制失败: " + e.getMessage()); + } + } + + private void refreshRecordScreenshot() { + runBackground(() -> { + try { + Path screenshot = service.captureRecordingScreenshot(); + Platform.runLater(() -> { + phoneImageView.setImage(new Image(screenshot.toUri().toString(), false)); + setRecordStatus("截图已刷新"); + }); + } catch (Exception e) { + Platform.runLater(() -> setRecordStatus("截图失败: " + e.getMessage())); + } + }); + } + + private void recordTap(double x, double y) { + Image image = phoneImageView.getImage(); + if (image == null) { + setRecordStatus("请先刷新截图"); + return; + } + runBackground(() -> { + try { + DisplayArea area = displayArea(image); + double localX = x - area.offsetX; + double localY = y - area.offsetY; + if (localX < 0 || localY < 0 || localX > area.width || localY > area.height) { + Platform.runLater(() -> setRecordStatus("点击位置不在手机画面内")); + return; + } + service.recordTap(localX, localY, area.width, area.height, + (int) image.getWidth(), (int) image.getHeight()); + Platform.runLater(() -> { + setRecordStatus("已记录点击: " + Math.round(x) + ", " + Math.round(y)); + refreshRecordScreenshot(); + }); + } catch (Exception e) { + Platform.runLater(() -> setRecordStatus("记录点击失败: " + e.getMessage())); + } + }); + } + + private void recordInput() { + String text = recordInputField.getText(); + runBackground(() -> { + try { + service.recordInput(text); + Platform.runLater(() -> { + recordInputField.clear(); + setRecordStatus("已记录输入"); + refreshRecordScreenshot(); + }); + } catch (Exception e) { + Platform.runLater(() -> setRecordStatus("输入失败: " + e.getMessage())); + } + }); + } + + private void recordBack() { + runBackground(() -> { + try { + service.recordBack(); + Platform.runLater(() -> { + setRecordStatus("已记录返回"); + refreshRecordScreenshot(); + }); + } catch (Exception e) { + Platform.runLater(() -> setRecordStatus("返回失败: " + e.getMessage())); + } + }); + } + + private void stopRecording() { + try { + Path yaml = service.stopRecording(); + refreshCases(); + setRecordStatus("已生成用例: " + yaml.getFileName()); + setStatus("录制用例已生成: " + yaml.toAbsolutePath(), false); + } catch (Exception e) { + setRecordStatus("停止录制失败: " + e.getMessage()); + } + } + + private void appendLog(String message) { + if (logArea != null) { + logArea.appendText(message + System.lineSeparator()); + } + } + + private void setStatus(String message, boolean error) { + statusLabel.setText(message); + statusLabel.setTextFill(error ? Theme.DANGER : Theme.TEXT_SECONDARY); + } + + private void setRecordStatus(String message) { + recordStatusLabel.setText(message); + recordStatusLabel.setTextFill(message.contains("失败") ? Color.web("#ef4444") : Theme.TEXT_SECONDARY); + } + + private void openPath(Path path) { + try { + java.nio.file.Files.createDirectories(path); + SystemOpenHelper.revealInFileManager(path); + } catch (Exception e) { + setStatus("打开目录失败: " + e.getMessage(), true); + } + } + + private void runBackground(Runnable runnable) { + Thread thread = new Thread(runnable, "mobile-ui-auto-ui-worker"); + thread.setDaemon(true); + thread.start(); + } + + private DisplayArea displayArea(Image image) { + double fitWidth = phoneImageView.getFitWidth(); + double fitHeight = phoneImageView.getFitHeight(); + double imageWidth = image.getWidth(); + double imageHeight = image.getHeight(); + double scale = Math.min(fitWidth / imageWidth, fitHeight / imageHeight); + double width = imageWidth * scale; + double height = imageHeight * scale; + return new DisplayArea((fitWidth - width) / 2, (fitHeight - height) / 2, width, height); + } + + private static class DisplayArea { + final double offsetX; + final double offsetY; + final double width; + final double height; + + DisplayArea(double offsetX, double offsetY, double width, double height) { + this.offsetX = offsetX; + this.offsetY = offsetY; + this.width = width; + this.height = height; + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/MobileAutomationService.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/MobileAutomationService.java new file mode 100644 index 0000000..a3af54f --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/MobileAutomationService.java @@ -0,0 +1,755 @@ +package com.example.workutils.biz.mobileuiauto.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.example.workutils.biz.mobileuiauto.service.model.*; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +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.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +public class MobileAutomationService { + + private static final DateTimeFormatter RUN_ID_FMT = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final String[] PYTHON_RESOURCES = { + "mobile-ui-auto/python/runner.py", + "mobile-ui-auto/python/requirements.txt" + }; + private static final String[] TEMPLATE_RESOURCES = { + "mobile-ui-auto/templates/sample-login-flow.yaml" + }; + + private final Path baseDir = Paths.get(System.getProperty("user.dir")).resolve("data/mobile-ui-auto"); + private final Path pythonDir = baseDir.resolve("python"); + private final Path casesDir = baseDir.resolve("cases"); + private final Path imagesDir = baseDir.resolve("images"); + private final Path reportsDir = baseDir.resolve("reports"); + private final Path venvDir = baseDir.resolve(".venv"); + private final Map running = new ConcurrentHashMap<>(); + + private RecordingSession recordingSession; + + @PostConstruct + public void init() { + try { + Files.createDirectories(baseDir); + Files.createDirectories(pythonDir); + Files.createDirectories(casesDir); + Files.createDirectories(imagesDir); + Files.createDirectories(reportsDir); + copyResources(PYTHON_RESOURCES, pythonDir); + copyResources(TEMPLATE_RESOURCES, casesDir); + } catch (IOException e) { + log.warn("初始化手机UI自动化目录失败: {}", e.getMessage(), e); + } + } + + public Path getBaseDir() { + return baseDir; + } + + public Path getCasesDir() { + return casesDir; + } + + public Path getReportsDir() { + return reportsDir; + } + + public Path getVenvDir() { + return venvDir; + } + + public void initializeProjectEnvironment(AutomationCallback callback) throws IOException { + Files.createDirectories(baseDir); + Files.createDirectories(pythonDir); + Path python = resolvePythonExecutable(); + if (Files.exists(python)) { + String version = detectPythonVersion(python); + if (!isSupportedPythonVersion(version)) { + callback.onEvent("log", "WARN", "当前项目环境 Python 版本不兼容: " + version); + callback.onEvent("log", "WARN", "将重建 .venv,建议使用 Python 3.10 或 3.11 运行 Airtest/Poco。"); + deleteRecursive(venvDir); + python = resolvePythonExecutable(); + } else { + callback.onEvent("log", "INFO", "项目 Python 环境已存在: " + python.toAbsolutePath() + " (" + version + ")"); + } + } + if (!Files.exists(python)) { + callback.onEvent("log", "INFO", "创建项目 Python 环境: " + venvDir.toAbsolutePath()); + callback.onEvent("log", "INFO", "正在查找 Python 3.11 / 3.10 / 3.12,不再使用不兼容的 Python 3.14。"); + CmdResult create = runStreamingCommand(buildVenvCreateCommand(), baseDir, 120, callback); + if (!create.ok) { + throw new IOException("创建虚拟环境失败: " + create.error); + } + python = resolvePythonExecutable(); + String version = detectPythonVersion(python); + if (!isSupportedPythonVersion(version)) { + throw new IOException("创建出的 Python 版本不兼容: " + version + "。请安装 Python 3.10 或 3.11。"); + } + } + + Path requirements = pythonDir.resolve("requirements.txt"); + List installCommand = Arrays.asList( + python.toString(), "-m", "pip", "install", "-U", "-r", requirements.toString()); + callback.onEvent("log", "INFO", "安装依赖: " + String.join(" ", installCommand)); + CmdResult install = runStreamingCommand(installCommand, baseDir, 600, callback); + if (!install.ok) { + throw new IOException("安装 Python 依赖失败: " + install.error); + } + callback.onEvent("log", "INFO", "Python 依赖安装完成"); + } + + public EnvironmentCheckResult checkEnvironment() { + EnvironmentCheckResult result = new EnvironmentCheckResult(); + Path python = resolvePythonExecutable(); + result.add("Project venv", Files.exists(python), + Files.exists(python) ? venvDir.toAbsolutePath().toString() : "未初始化,请点击“初始化环境”"); + addCheck(result, "Python", Arrays.asList(python.toString(), "--version")); + addCheck(result, "pip", Arrays.asList(python.toString(), "-m", "pip", "--version")); + addCheck(result, "airtest", Arrays.asList(python.toString(), "-c", "import airtest; print('airtest ok')")); + addCheck(result, "pocoui", Arrays.asList(python.toString(), "-c", "import poco; print('pocoui ok')")); + Optional adb = resolveAdbExecutable(); + if (adb.isPresent()) { + addCheck(result, "ADB", Arrays.asList(adb.get().toString(), "devices")); + } else { + result.add("ADB", false, "未找到 adb。初始化环境后优先使用 Airtest 自带 adb,也可安装 Android platform-tools。"); + } + return result; + } + + public List listDevices() throws IOException { + Path adb = resolveAdbExecutable().orElseThrow(() -> new IOException("未找到 adb,请先初始化环境或安装 Android platform-tools")); + CmdResult result = runCommand(Arrays.asList(adb.toString(), "devices", "-l"), null, 10); + if (!result.ok) { + throw new IOException(result.error); + } + List devices = new ArrayList<>(); + String[] lines = result.output.split("\\R"); + for (String line : lines) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("List of devices")) { + continue; + } + String[] parts = line.split("\\s+"); + if (parts.length < 2) { + continue; + } + String serial = parts[0]; + String state = parts[1]; + String model = ""; + String product = ""; + for (String part : parts) { + if (part.startsWith("model:")) { + model = part.substring("model:".length()); + } else if (part.startsWith("product:")) { + product = part.substring("product:".length()); + } + } + devices.add(new DeviceInfo(serial, state, model, product, line)); + } + return devices; + } + + public List listCases() { + List result = new ArrayList<>(); + try { + if (!Files.exists(casesDir)) { + return result; + } + try (java.util.stream.Stream stream = Files.list(casesDir)) { + stream.filter(Files::isRegularFile) + .filter(this::isCaseFile) + .sorted() + .forEach(result::add); + } + } catch (IOException e) { + log.warn("读取用例目录失败: {}", e.getMessage(), e); + } + return result; + } + + public AutomationRunHandle runCase(AutomationRunConfig config, AutomationCallback callback) throws IOException { + validateRunConfig(config); + String runId = LocalDateTime.now().format(RUN_ID_FMT); + Path reportDir = reportsDir.resolve(runId); + Files.createDirectories(reportDir); + + List command = new ArrayList<>(); + command.add(resolvePythonExecutable().toString()); + command.add(pythonDir.resolve("runner.py").toString()); + command.add("--device"); + command.add("Android:///" + nullToEmpty(config.getDeviceSerial())); + command.add("--package"); + command.add(nullToEmpty(config.getPackageName())); + command.add("--activity"); + command.add(nullToEmpty(config.getActivityName())); + command.add("--case"); + command.add(config.getCasePath().toAbsolutePath().toString()); + command.add("--report"); + command.add(reportDir.toAbsolutePath().toString()); + command.add("--timeout"); + command.add(String.valueOf(config.getTimeoutSeconds())); + if (config.isScreenshotOnFail()) { + command.add("--screenshot-on-fail"); + } + + ProcessBuilder pb = new ProcessBuilder(command); + pb.directory(baseDir.toFile()); + pb.redirectErrorStream(false); + Process process = pb.start(); + AutomationRunHandle handle = new AutomationRunHandle(runId, process); + running.put(runId, handle); + callback.onEvent("log", "INFO", "开始运行: " + config.getCasePath().getFileName()); + callback.onEvent("log", "INFO", "报告目录: " + reportDir.toAbsolutePath()); + + Thread out = new Thread(() -> readRunnerOutput(process.getInputStream(), callback), "mobile-ui-runner-out"); + Thread err = new Thread(() -> readRunnerError(process.getErrorStream(), callback), "mobile-ui-runner-err"); + out.setDaemon(true); + err.setDaemon(true); + out.start(); + err.start(); + + Thread watcher = new Thread(() -> { + boolean success = false; + String message; + try { + int exit = process.waitFor(); + success = exit == 0; + message = success ? "运行完成" : "运行失败,退出码: " + exit; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + message = "运行被中断"; + } finally { + running.remove(runId); + } + callback.onDone(new AutomationRunResult(runId, success, message, reportDir)); + }, "mobile-ui-runner-watch"); + watcher.setDaemon(true); + watcher.start(); + + return handle; + } + + public void stopRun(String runId) { + AutomationRunHandle handle = running.get(runId); + if (handle != null) { + handle.stop(); + running.remove(runId); + } + } + + public synchronized void startRecording(String deviceSerial, String packageName) throws IOException { + String runId = "record-" + LocalDateTime.now().format(RUN_ID_FMT); + Path recordDir = reportsDir.resolve(runId); + Files.createDirectories(recordDir.resolve("screenshots")); + recordingSession = new RecordingSession(runId, deviceSerial, packageName, recordDir); + } + + public synchronized boolean isRecording() { + return recordingSession != null; + } + + public synchronized Path captureRecordingScreenshot() throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + Path screenshot = recordingSession.recordDir.resolve("screenshots/current.png"); + captureScreenshot(recordingSession.deviceSerial, screenshot); + return screenshot; + } + + public synchronized void recordTap(double imageX, double imageY, double imageWidth, double imageHeight, int screenWidth, int screenHeight) throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + int x = scale(imageX, imageWidth, screenWidth); + int y = scale(imageY, imageHeight, screenHeight); + runAdb(recordingSession.deviceSerial, Arrays.asList("shell", "input", "tap", String.valueOf(x), String.valueOf(y)), 10); + recordingSession.addTap(x, y, screenWidth, screenHeight); + } + + public synchronized void recordInput(String text) throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + if (text == null) { + text = ""; + } + runAdb(recordingSession.deviceSerial, Arrays.asList("shell", "input", "text", escapeAdbInput(text)), 10); + recordingSession.addInput(text); + } + + public synchronized void recordBack() throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + runAdb(recordingSession.deviceSerial, Arrays.asList("shell", "input", "keyevent", "BACK"), 10); + recordingSession.addKeyevent("BACK"); + } + + public synchronized void recordSwipe(int startX, int startY, int endX, int endY, int durationMs) throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + runAdb(recordingSession.deviceSerial, Arrays.asList("shell", "input", "swipe", + String.valueOf(startX), String.valueOf(startY), String.valueOf(endX), String.valueOf(endY), String.valueOf(durationMs)), 10); + recordingSession.addSwipe(startX, startY, endX, endY, durationMs); + } + + public synchronized Path stopRecording() throws IOException { + if (recordingSession == null) { + throw new IOException("尚未开始录制"); + } + Path file = casesDir.resolve(recordingSession.runId + ".yaml"); + Files.write(file, recordingSession.toYaml().getBytes(StandardCharsets.UTF_8)); + Path latest = casesDir.resolve("latest-recorded-flow.yaml"); + Files.copy(file, latest, StandardCopyOption.REPLACE_EXISTING); + recordingSession = null; + return file; + } + + private void captureScreenshot(String serial, Path output) throws IOException { + List command = new ArrayList<>(); + command.add(resolveAdbExecutable().orElse(Path.of("adb")).toString()); + if (serial != null && !serial.trim().isEmpty()) { + command.add("-s"); + command.add(serial.trim()); + } + command.add("exec-out"); + command.add("screencap"); + command.add("-p"); + Process process = new ProcessBuilder(command).start(); + byte[] bytes; + try (InputStream in = process.getInputStream()) { + bytes = in.readAllBytes(); + } + try { + if (!process.waitFor(10, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("截图超时"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("截图被中断", e); + } + if (process.exitValue() != 0 || bytes.length == 0) { + throw new IOException("截图失败,请确认设备已连接并授权"); + } + Files.createDirectories(output.getParent()); + Files.write(output, bytes); + } + + private void validateRunConfig(AutomationRunConfig config) { + if (config == null) { + throw new IllegalArgumentException("运行配置不能为空"); + } + if (config.getCasePath() == null || !Files.exists(config.getCasePath())) { + throw new IllegalArgumentException("请选择有效用例文件"); + } + } + + private boolean isCaseFile(Path path) { + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + return name.endsWith(".yaml") || name.endsWith(".yml") || name.endsWith(".py") || name.endsWith(".air"); + } + + private void addCheck(EnvironmentCheckResult result, String name, List command) { + CmdResult cmdResult = runCommand(command, null, 10); + String detail = cmdResult.ok ? firstLine(cmdResult.output) : cmdResult.error; + result.add(name, cmdResult.ok, detail); + } + + private void readRunnerOutput(InputStream input, AutomationCallback callback) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + handleRunnerLine(line, callback); + } + } catch (IOException e) { + callback.onEvent("log", "ERROR", "读取运行日志失败: " + e.getMessage()); + } + } + + private void readRunnerError(InputStream input, AutomationCallback callback) { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + callback.onEvent("log", "ERROR", line); + } + } catch (IOException e) { + callback.onEvent("log", "ERROR", "读取错误日志失败: " + e.getMessage()); + } + } + + private void handleRunnerLine(String line, AutomationCallback callback) { + if (line == null || line.isEmpty()) { + return; + } + try { + JSONObject object = JSON.parseObject(line); + String type = object.getString("type"); + String level = object.getString("level"); + String message = object.getString("message"); + if (message == null) { + message = object.toJSONString(); + } + callback.onEvent(type != null ? type : "log", level != null ? level : "INFO", message); + } catch (Exception ignored) { + callback.onEvent("log", "INFO", line); + } + } + + private void runAdb(String serial, List args, int timeoutSeconds) throws IOException { + CmdResult result = runCommand(buildAdbCommand(serial, args), null, timeoutSeconds); + if (!result.ok) { + throw new IOException(result.error); + } + } + + private CmdResult runCommand(List command, Path workingDir, int timeoutSeconds) { + CmdResult result = new CmdResult(); + Process process = null; + try { + ProcessBuilder pb = new ProcessBuilder(command); + if (workingDir != null) { + pb.directory(workingDir.toFile()); + } + pb.redirectErrorStream(true); + process = pb.start(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Process proc = process; + Thread reader = new Thread(() -> { + try (InputStream in = proc.getInputStream()) { + in.transferTo(out); + } catch (IOException ignored) { + } + }, "mobile-ui-cmd-reader"); + reader.setDaemon(true); + reader.start(); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + result.ok = false; + result.error = "命令超时: " + String.join(" ", command); + return result; + } + reader.join(1000); + result.output = out.toString(StandardCharsets.UTF_8); + result.ok = process.exitValue() == 0; + if (!result.ok) { + result.error = result.output.isEmpty() ? "exit code " + process.exitValue() : result.output.trim(); + } + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + result.ok = false; + result.error = e.getMessage(); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + return result; + } + + private CmdResult runStreamingCommand(List command, Path workingDir, int timeoutSeconds, AutomationCallback callback) { + CmdResult result = new CmdResult(); + Process process = null; + StringBuilder output = new StringBuilder(); + try { + ProcessBuilder pb = new ProcessBuilder(command); + if (workingDir != null) { + pb.directory(workingDir.toFile()); + } + pb.redirectErrorStream(true); + process = pb.start(); + Process proc = process; + Thread reader = new Thread(() -> { + try (BufferedReader br = new BufferedReader(new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + output.append(line).append(System.lineSeparator()); + callback.onEvent("log", "INFO", line); + } + } catch (IOException e) { + callback.onEvent("log", "ERROR", "读取安装输出失败: " + e.getMessage()); + } + }, "mobile-ui-stream-reader"); + reader.setDaemon(true); + reader.start(); + + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + result.ok = false; + result.output = output.toString(); + result.error = "命令超时: " + String.join(" ", command); + callback.onEvent("log", "ERROR", result.error); + return result; + } + reader.join(3000); + result.output = output.toString(); + result.ok = process.exitValue() == 0; + if (!result.ok) { + result.error = result.output.isEmpty() ? "exit code " + process.exitValue() : result.output.trim(); + } + } catch (Exception e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + result.ok = false; + result.output = output.toString(); + result.error = e.getMessage(); + callback.onEvent("log", "ERROR", result.error); + } finally { + if (process != null && process.isAlive()) { + process.destroyForcibly(); + } + } + return result; + } + + private List buildAdbCommand(String serial, List args) { + List command = new ArrayList<>(); + command.add(resolveAdbExecutable().orElse(Path.of("adb")).toString()); + if (serial != null && !serial.trim().isEmpty()) { + command.add("-s"); + command.add(serial.trim()); + } + command.addAll(args); + return command; + } + + private Path resolvePythonExecutable() { + Path windowsPython = venvDir.resolve("Scripts/python.exe"); + if (Files.exists(windowsPython)) { + return windowsPython; + } + Path unixPython = venvDir.resolve("bin/python"); + if (Files.exists(unixPython)) { + return unixPython; + } + return Path.of("python"); + } + + private List buildVenvCreateCommand() throws IOException { + CmdResult py311 = runCommand(Arrays.asList("py", "-3.11", "--version"), baseDir, 10); + if (py311.ok) { + return Arrays.asList("py", "-3.11", "-m", "venv", venvDir.toString()); + } + CmdResult py310 = runCommand(Arrays.asList("py", "-3.10", "--version"), baseDir, 10); + if (py310.ok) { + return Arrays.asList("py", "-3.10", "-m", "venv", venvDir.toString()); + } + CmdResult py312 = runCommand(Arrays.asList("py", "-3.12", "--version"), baseDir, 10); + if (py312.ok) { + return Arrays.asList("py", "-3.12", "-m", "venv", venvDir.toString()); + } + throw new IOException("未检测到 Python 3.10/3.11/3.12。请安装 Python 3.11 后重新点击“初始化环境”。"); + } + + private String detectPythonVersion(Path python) { + if (python == null || !Files.exists(python)) { + return ""; + } + CmdResult version = runCommand(Arrays.asList(python.toString(), "--version"), baseDir, 10); + if (!version.ok) { + return ""; + } + return firstLine(version.output); + } + + private boolean isSupportedPythonVersion(String version) { + if (version == null) { + return false; + } + return version.startsWith("Python 3.10.") + || version.startsWith("Python 3.11.") + || version.startsWith("Python 3.12."); + } + + private void deleteRecursive(Path path) throws IOException { + if (path == null || !Files.exists(path)) { + return; + } + Path normalizedBase = baseDir.toAbsolutePath().normalize(); + Path normalizedTarget = path.toAbsolutePath().normalize(); + if (!normalizedTarget.startsWith(normalizedBase) || normalizedTarget.equals(normalizedBase)) { + throw new IOException("拒绝删除非插件数据目录: " + normalizedTarget); + } + try (java.util.stream.Stream stream = Files.walk(path)) { + List paths = new ArrayList<>(); + stream.forEach(paths::add); + paths.sort(Comparator.reverseOrder()); + for (Path p : paths) { + File file = p.toFile(); + if (!file.canWrite()) { + file.setWritable(true); + } + Files.deleteIfExists(p); + } + } + } + + private Optional resolveAdbExecutable() { + List candidates = new ArrayList<>(); + candidates.add(venvDir.resolve("Lib/site-packages/airtest/core/android/static/adb/windows/adb.exe")); + candidates.add(venvDir.resolve("lib/python3.11/site-packages/airtest/core/android/static/adb/linux/adb")); + candidates.add(venvDir.resolve("lib/python3.10/site-packages/airtest/core/android/static/adb/linux/adb")); + candidates.add(venvDir.resolve("lib/python3.9/site-packages/airtest/core/android/static/adb/linux/adb")); + for (Path candidate : candidates) { + if (Files.exists(candidate)) { + return Optional.of(candidate); + } + } + CmdResult systemAdb = runCommand(Arrays.asList("adb", "version"), null, 10); + if (systemAdb.ok) { + return Optional.of(Path.of("adb")); + } + return Optional.empty(); + } + + private void copyResources(String[] resources, Path targetDir) throws IOException { + for (String resource : resources) { + String fileName = resource.substring(resource.lastIndexOf('/') + 1); + Path target = targetDir.resolve(fileName); + if (Files.exists(target) && Files.size(target) > 0) { + continue; + } + try (InputStream in = Thread.currentThread().getContextClassLoader().getResourceAsStream(resource)) { + if (in == null) { + log.warn("未找到资源: {}", resource); + continue; + } + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + + private int scale(double value, double imageSize, int screenSize) { + if (imageSize <= 0) { + return 0; + } + return (int) Math.round(value / imageSize * screenSize); + } + + private String escapeAdbInput(String text) { + return text.replace(" ", "%s") + .replace("&", "\\&") + .replace("<", "\\<") + .replace(">", "\\>") + .replace("|", "\\|"); + } + + private String firstLine(String text) { + if (text == null) { + return ""; + } + String[] lines = text.trim().split("\\R", 2); + return lines.length == 0 ? "" : lines[0]; + } + + private String nullToEmpty(String value) { + return value == null ? "" : value; + } + + private static class CmdResult { + boolean ok; + String output = ""; + String error = ""; + } + + private static class RecordingSession { + final String runId; + final String deviceSerial; + final String packageName; + final Path recordDir; + final List steps = new ArrayList<>(); + int index = 1; + + RecordingSession(String runId, String deviceSerial, String packageName, Path recordDir) { + this.runId = runId; + this.deviceSerial = deviceSerial; + this.packageName = packageName; + this.recordDir = recordDir; + } + + void addTap(int x, int y, int screenWidth, int screenHeight) { + steps.add(" - name: tap-" + index + "\n" + + " action: tap\n" + + " target:\n" + + " type: coord\n" + + " preferred: coord\n" + + " x: " + x + "\n" + + " y: " + y + "\n" + + " screenWidth: " + screenWidth + "\n" + + " screenHeight: " + screenHeight + "\n" + + " waitAfterMs: 500"); + index++; + } + + void addInput(String text) { + steps.add(" - name: input-" + index + "\n" + + " action: input_text\n" + + " text: \"" + yamlEscape(text) + "\"\n" + + " waitAfterMs: 500"); + index++; + } + + void addKeyevent(String key) { + steps.add(" - name: key-" + index + "\n" + + " action: keyevent\n" + + " key: " + key + "\n" + + " waitAfterMs: 500"); + index++; + } + + void addSwipe(int startX, int startY, int endX, int endY, int durationMs) { + steps.add(" - name: swipe-" + index + "\n" + + " action: swipe\n" + + " start: {x: " + startX + ", y: " + startY + "}\n" + + " end: {x: " + endX + ", y: " + endY + "}\n" + + " durationMs: " + durationMs + "\n" + + " waitAfterMs: 500"); + index++; + } + + String toYaml() { + StringBuilder yaml = new StringBuilder(); + yaml.append("name: ").append(runId).append("\n"); + yaml.append("app:\n"); + yaml.append(" package: ").append(packageName == null ? "" : packageName).append("\n"); + yaml.append(" activity: \n"); + yaml.append("device:\n"); + yaml.append(" connect: Android:///"); + yaml.append(deviceSerial == null ? "" : deviceSerial).append("\n"); + yaml.append("settings:\n"); + yaml.append(" timeout: 20\n"); + yaml.append(" screenshotOnFail: true\n"); + yaml.append("steps:\n"); + for (String step : steps) { + yaml.append(step).append("\n"); + } + return yaml.toString(); + } + + private String yamlEscape(String text) { + return text.replace("\\", "\\\\").replace("\"", "\\\""); + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationCallback.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationCallback.java new file mode 100644 index 0000000..5e516dc --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationCallback.java @@ -0,0 +1,8 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +public interface AutomationCallback { + + void onEvent(String type, String level, String message); + + void onDone(AutomationRunResult result); +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunConfig.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunConfig.java new file mode 100644 index 0000000..3d72eb0 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunConfig.java @@ -0,0 +1,61 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +import java.nio.file.Path; + +public class AutomationRunConfig { + + private String deviceSerial; + private String packageName; + private String activityName; + private Path casePath; + private int timeoutSeconds = 20; + private boolean screenshotOnFail = true; + + public String getDeviceSerial() { + return deviceSerial; + } + + public void setDeviceSerial(String deviceSerial) { + this.deviceSerial = deviceSerial; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getActivityName() { + return activityName; + } + + public void setActivityName(String activityName) { + this.activityName = activityName; + } + + public Path getCasePath() { + return casePath; + } + + public void setCasePath(Path casePath) { + this.casePath = casePath; + } + + public int getTimeoutSeconds() { + return timeoutSeconds; + } + + public void setTimeoutSeconds(int timeoutSeconds) { + this.timeoutSeconds = timeoutSeconds; + } + + public boolean isScreenshotOnFail() { + return screenshotOnFail; + } + + public void setScreenshotOnFail(boolean screenshotOnFail) { + this.screenshotOnFail = screenshotOnFail; + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunHandle.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunHandle.java new file mode 100644 index 0000000..936ce7c --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunHandle.java @@ -0,0 +1,34 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +public class AutomationRunHandle { + + private final String runId; + private final Process process; + + public AutomationRunHandle(String runId, Process process) { + this.runId = runId; + this.process = process; + } + + public String getRunId() { + return runId; + } + + public boolean isAlive() { + return process != null && process.isAlive(); + } + + public void stop() { + if (process != null && process.isAlive()) { + process.destroy(); + try { + if (!process.waitFor(3, java.util.concurrent.TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + process.destroyForcibly(); + } + } + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunResult.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunResult.java new file mode 100644 index 0000000..27234d1 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/AutomationRunResult.java @@ -0,0 +1,34 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +import java.nio.file.Path; + +public class AutomationRunResult { + + private final String runId; + private final boolean success; + private final String message; + private final Path reportDir; + + public AutomationRunResult(String runId, boolean success, String message, Path reportDir) { + this.runId = runId; + this.success = success; + this.message = message; + this.reportDir = reportDir; + } + + public String getRunId() { + return runId; + } + + public boolean isSuccess() { + return success; + } + + public String getMessage() { + return message; + } + + public Path getReportDir() { + return reportDir; + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/DeviceInfo.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/DeviceInfo.java new file mode 100644 index 0000000..1f53dda --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/DeviceInfo.java @@ -0,0 +1,51 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +public class DeviceInfo { + + private final String serial; + private final String state; + private final String model; + private final String product; + private final String raw; + + public DeviceInfo(String serial, String state, String model, String product, String raw) { + this.serial = serial; + this.state = state; + this.model = model; + this.product = product; + this.raw = raw; + } + + public String getSerial() { + return serial; + } + + public String getState() { + return state; + } + + public String getModel() { + return model; + } + + public String getProduct() { + return product; + } + + public String getRaw() { + return raw; + } + + public boolean isOnline() { + return "device".equalsIgnoreCase(state); + } + + @Override + public String toString() { + String label = serial + " [" + state + "]"; + if (model != null && !model.isEmpty()) { + label += " " + model; + } + return label; + } +} diff --git a/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/EnvironmentCheckResult.java b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/EnvironmentCheckResult.java new file mode 100644 index 0000000..28a449d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/mobileuiauto/service/model/EnvironmentCheckResult.java @@ -0,0 +1,51 @@ +package com.example.workutils.biz.mobileuiauto.service.model; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class EnvironmentCheckResult { + + private final List items = new ArrayList<>(); + + public void add(String name, boolean ok, String detail) { + items.add(new Item(name, ok, detail)); + } + + public List getItems() { + return Collections.unmodifiableList(items); + } + + public boolean isOk() { + for (Item item : items) { + if (!item.isOk()) { + return false; + } + } + return true; + } + + public static class Item { + private final String name; + private final boolean ok; + private final String detail; + + public Item(String name, boolean ok, String detail) { + this.name = name; + this.ok = ok; + this.detail = detail; + } + + public String getName() { + return name; + } + + public boolean isOk() { + return ok; + } + + public String getDetail() { + return detail; + } + } +} diff --git a/src/main/resources/mobile-ui-auto/python/requirements.txt b/src/main/resources/mobile-ui-auto/python/requirements.txt new file mode 100644 index 0000000..94f7e25 --- /dev/null +++ b/src/main/resources/mobile-ui-auto/python/requirements.txt @@ -0,0 +1,3 @@ +airtest +pocoui +pyyaml diff --git a/src/main/resources/mobile-ui-auto/python/runner.py b/src/main/resources/mobile-ui-auto/python/runner.py new file mode 100644 index 0000000..165cf29 --- /dev/null +++ b/src/main/resources/mobile-ui-auto/python/runner.py @@ -0,0 +1,289 @@ +import argparse +import json +import os +import subprocess +import sys +import time +from datetime import datetime + + +def emit(event_type, level="INFO", message="", **extra): + payload = {"type": event_type, "level": level, "message": message} + payload.update(extra) + print(json.dumps(payload, ensure_ascii=False), flush=True) + + +def load_yaml(path): + try: + import yaml + except Exception as exc: + emit("log", "ERROR", "缺少 PyYAML,请执行: python -m pip install -U pyyaml") + raise exc + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def ensure_airtest(): + try: + from airtest.core.api import ( + G, + Template, + auto_setup, + connect_device, + exists, + keyevent, + sleep, + snapshot, + start_app, + stop_app, + swipe, + text, + touch, + wait, + ) + from poco.drivers.android.uiautomation import AndroidUiautomationPoco + return { + "G": G, + "Template": Template, + "auto_setup": auto_setup, + "connect_device": connect_device, + "exists": exists, + "keyevent": keyevent, + "sleep": sleep, + "snapshot": snapshot, + "start_app": start_app, + "stop_app": stop_app, + "swipe": swipe, + "text": text, + "touch": touch, + "wait": wait, + "AndroidUiautomationPoco": AndroidUiautomationPoco, + } + except Exception as exc: + emit("log", "ERROR", "缺少 Airtest/Poco,请执行: python -m pip install -U airtest pocoui") + raise exc + + +def run_python_case(case_path, args): + command = [sys.executable, case_path] + emit("log", "INFO", "运行 Python 用例: " + " ".join(command)) + proc = subprocess.run(command, cwd=os.path.dirname(case_path) or ".", text=True) + return proc.returncode + + +def run_air_case(case_path, args): + command = [sys.executable, "-m", "airtest", "run", case_path, "--device", args.device] + emit("log", "INFO", "运行 Airtest 用例: " + " ".join(command)) + proc = subprocess.run(command, cwd=os.path.dirname(case_path) or ".", text=True) + return proc.returncode + + +def connect(api, args): + os.makedirs(args.report, exist_ok=True) + api["auto_setup"](__file__, logdir=args.report, devices=[args.device]) + poco = None + try: + poco = api["AndroidUiautomationPoco"](use_airtest_input=True, screenshot_each_action=False) + emit("log", "INFO", "Poco 初始化成功") + except Exception as exc: + emit("log", "WARN", "Poco 初始化失败,将仅使用 Airtest/坐标/图片定位: " + str(exc)) + return poco + + +def find_poco(poco, query): + if poco is None: + raise RuntimeError("Poco 未初始化") + query = query or {} + if "name" in query and query["name"]: + return poco(query["name"]) + if "text" in query and query["text"]: + return poco(text=query["text"]) + if "type" in query and query["type"]: + return poco(type=query["type"]) + raise RuntimeError("Poco 查询条件为空") + + +def target_path(case_dir, path): + if os.path.isabs(path): + return path + return os.path.join(case_dir, path) + + +def tap_target(api, poco, target, case_dir): + target = target or {} + target_type = target.get("type") or target.get("preferred") or "coord" + if target_type == "poco": + node = find_poco(poco, target.get("query") or target.get("poco")) + node.wait_for_appearance(timeout=target.get("timeout", 10)) + node.click() + return + if target_type == "image": + template = api["Template"](target_path(case_dir, target["path"]), threshold=target.get("threshold", 0.85)) + api["touch"](template) + return + x = int(target.get("x", 0)) + y = int(target.get("y", 0)) + screen_width = int(target.get("screenWidth", 0) or 0) + screen_height = int(target.get("screenHeight", 0) or 0) + try: + current_width, current_height = api["G"].DEVICE.get_current_resolution() + if screen_width > 0 and screen_height > 0: + x = round(x / screen_width * current_width) + y = round(y / screen_height * current_height) + except Exception: + pass + api["touch"]((x, y)) + + +def wait_target(api, poco, target, case_dir, timeout): + target = target or {} + target_type = target.get("type") or target.get("preferred") or "coord" + if target_type == "poco": + node = find_poco(poco, target.get("query") or target.get("poco")) + node.wait_for_appearance(timeout=timeout) + return + if target_type == "image": + api["wait"](api["Template"](target_path(case_dir, target["path"]), threshold=target.get("threshold", 0.85)), timeout=timeout) + return + time.sleep(min(timeout, 1)) + + +def assert_exists(api, poco, target, case_dir): + target = target or {} + target_type = target.get("type") or target.get("preferred") or "coord" + if target_type == "poco": + node = find_poco(poco, target.get("query") or target.get("poco")) + if not node.exists(): + raise AssertionError("Poco 元素不存在: " + json.dumps(target, ensure_ascii=False)) + return + if target_type == "image": + if not api["exists"](api["Template"](target_path(case_dir, target["path"]), threshold=target.get("threshold", 0.85))): + raise AssertionError("图片不存在: " + target["path"]) + + +def run_yaml_case(case_path, args): + api = ensure_airtest() + case = load_yaml(case_path) + case_dir = os.path.dirname(case_path) + poco = connect(api, args) + package_name = args.package or (case.get("app") or {}).get("package") + activity_name = args.activity or (case.get("app") or {}).get("activity") + settings = case.get("settings") or {} + default_timeout = int(args.timeout or settings.get("timeout") or 20) + steps = case.get("steps") or [] + result = { + "caseName": case.get("name") or os.path.basename(case_path), + "device": args.device, + "package": package_name, + "success": False, + "startTime": datetime.now().isoformat(), + "steps": [], + } + + if package_name: + emit("log", "INFO", "启动 APP: " + package_name) + if activity_name: + api["start_app"](package_name, activity=activity_name) + else: + api["start_app"](package_name) + + for idx, step in enumerate(steps, start=1): + name = step.get("name") or f"step-{idx}" + action = step.get("action") + started = time.time() + emit("step_start", "INFO", f"{idx}. {name}", index=idx, name=name) + record = {"index": idx, "name": name, "action": action, "status": "PASS"} + try: + if action in ("tap", "click"): + tap_target(api, poco, step.get("target"), case_dir) + elif action in ("input", "input_text"): + target = step.get("target") + if target: + tap_target(api, poco, target, case_dir) + api["text"](str(step.get("text", ""))) + elif action == "wait": + wait_target(api, poco, step.get("target"), case_dir, int(step.get("timeout") or default_timeout)) + elif action == "sleep": + api["sleep"](float(step.get("seconds") or step.get("duration") or 1)) + elif action == "keyevent": + api["keyevent"](step.get("key") or step.get("value") or "BACK") + elif action == "swipe": + start = step.get("start") or {} + end = step.get("end") or {} + api["swipe"]((int(start.get("x", 0)), int(start.get("y", 0))), (int(end.get("x", 0)), int(end.get("y", 0))), + duration=float(step.get("durationMs", 500)) / 1000) + elif action == "screenshot": + api["snapshot"](filename=os.path.join(args.report, step.get("path") or f"step_{idx}.png")) + elif action == "assert_exists": + assert_exists(api, poco, step.get("target"), case_dir) + elif action == "start_app": + api["start_app"](step.get("package") or package_name) + elif action == "stop_app": + api["stop_app"](step.get("package") or package_name) + else: + raise RuntimeError("不支持的 action: " + str(action)) + + wait_ms = int(step.get("waitAfterMs") or 0) + if wait_ms > 0: + time.sleep(wait_ms / 1000) + record["elapsedMs"] = round((time.time() - started) * 1000) + emit("step_pass", "INFO", f"{idx}. {name} 通过", index=idx, elapsedMs=record["elapsedMs"]) + except Exception as exc: + record["status"] = "FAIL" + record["message"] = str(exc) + if args.screenshot_on_fail: + screenshot = os.path.join(args.report, f"failed_step_{idx}.png") + try: + api["snapshot"](filename=screenshot) + record["screenshot"] = screenshot + emit("screenshot", "INFO", screenshot, path=screenshot) + except Exception: + pass + result["steps"].append(record) + result["endTime"] = datetime.now().isoformat() + result["success"] = False + write_result(args.report, result) + emit("step_fail", "ERROR", f"{idx}. {name} 失败: {exc}", index=idx) + return 1 + result["steps"].append(record) + + result["success"] = True + result["endTime"] = datetime.now().isoformat() + write_result(args.report, result) + emit("done", "INFO", "用例运行成功", reportPath=os.path.join(args.report, "result.json")) + return 0 + + +def write_result(report_dir, result): + os.makedirs(report_dir, exist_ok=True) + with open(os.path.join(report_dir, "result.json"), "w", encoding="utf-8") as fh: + json.dump(result, fh, ensure_ascii=False, indent=2) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--device", default="Android:///") + parser.add_argument("--package", default="") + parser.add_argument("--activity", default="") + parser.add_argument("--case", required=True) + parser.add_argument("--report", required=True) + parser.add_argument("--timeout", type=int, default=20) + parser.add_argument("--screenshot-on-fail", action="store_true") + args = parser.parse_args() + + case_path = os.path.abspath(args.case) + try: + if case_path.lower().endswith(".py"): + code = run_python_case(case_path, args) + elif case_path.lower().endswith(".air"): + code = run_air_case(case_path, args) + else: + code = run_yaml_case(case_path, args) + return code + except Exception as exc: + emit("done", "ERROR", "运行异常: " + str(exc)) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/main/resources/mobile-ui-auto/templates/sample-login-flow.yaml b/src/main/resources/mobile-ui-auto/templates/sample-login-flow.yaml new file mode 100644 index 0000000..7b588df --- /dev/null +++ b/src/main/resources/mobile-ui-auto/templates/sample-login-flow.yaml @@ -0,0 +1,24 @@ +name: sample-login-flow +app: + package: com.example.app + activity: +device: + connect: Android:/// +settings: + timeout: 20 + screenshotOnFail: true +steps: + - name: wait-a-moment + action: sleep + seconds: 1 + + - name: tap-example-coordinate + action: tap + target: + type: coord + preferred: coord + x: 540 + y: 1600 + screenWidth: 1080 + screenHeight: 2400 + waitAfterMs: 500