diff --git a/docs/03-能力接入规划与排期.md b/docs/03-能力接入规划与排期.md index 0bd58e5..0b71921 100644 --- a/docs/03-能力接入规划与排期.md +++ b/docs/03-能力接入规划与排期.md @@ -398,3 +398,23 @@ - 本文档为能力接入的唯一规划来源;新增能力须先在此登记阶段 - 2026-10-30 校准:数字以运行时注册表为准(744 个 id,目录可执行 732 / 1016);原排除/暂缓中已实现的 78 项已移入「已接入 / 已完成」 - 下一步:将第一节清单固化为 resources/excluded.json,组件库显示「不接入」灰标 + +## 六、桌面拾取与定位——原厂实现解读与我们的增强项(2026-10-30) + +### 原厂实在智能的实现(源码解读) + +- 拾取(ui_selector_window.py):全屏半透明覆盖窗 + 高亮窗贴**窗格级**元素 + 左键确认;输出 uiSelector = { window: [{class,type,name} 链], shot: 自动截图 }——**拾取时自动截取目标窗口图片存档** +- 执行定位(Element/py/scope/native.py):路径链逐层走 UIA 树——每层匹配 type/class/name **通配符** + 同名兄弟 **index 序号**;窗口锚用标题通配(EnumWindows) +- 桌面点击执行:**窗口定位 + 图像/坐标模式**(原厂 Image/CV 能力家族庞大的原因) + +### 我们当前方案对比 + +- 定位:UIA 属性选择器(name/id 精确匹配,单层)——精度高于原厂通配符,但**缺路径链/通配/序号 fallback** +- 拾取:v2 两阶段(悬停/微调)+ PageUp/PageDown 层级切换 + 最小化应用——已实现 + +### 登记的增强项(后续迭代) + +1. 链式查找:拾取器已输出元素链(layers),执行端支持逐层匹配(原厂模式)——解决 name 重复场景 +2. name 通配符匹配与同名序号 index +3. 拾取确认时自动截取目标窗口图片存档(调试回看) +4. WebView2 深层 DOM 控件的 UIA 暴露深度调优 diff --git a/docs/04-外接浏览器支持改动方案.md b/docs/04-外接浏览器支持改动方案.md new file mode 100644 index 0000000..12b09e1 --- /dev/null +++ b/docs/04-外接浏览器支持改动方案.md @@ -0,0 +1,186 @@ +# 04-外接浏览器支持改动方案(Edge / Chrome) + +- **编写时间**:2026-10-30 +- **编写人**:刘爱家 +- **关联文档**:01-现状分析与可行性 / 02-实施方案 / 03-能力接入规划与排期 +- **目标**:让「打开网页」及全部网页自动化组件支持在客户本机的 Edge / Chrome 浏览器中执行,不再强制使用内置运行浏览器;内置浏览器保留为默认兜底。 + +--- + +## 一、现状分析(代码级) + +### 1.1 现有架构 + +``` +网页组件(web.ts / web-batch2.ts / web-crawl.ts,169 项目录项) + │ BrowserLike 接口(engine/types.ts,33 个方法) + ▼ +BrowserSession(browser/session.ts,1056 行,单例) + │ this.cdp(method, params) + ▼ +win.webContents.debugger(Electron 内置窗口「RPA 运行浏览器」) +``` + +- **会话模型**:`BrowserSession.create()` 单例复用一个 `BrowserWindow`,全局变量 `__rpaBrowserWindow` 持有;单窗口单 Tab 模型(`tab_unavailable` 错误即来自此)。 +- **CDP 使用现状**:全部经 `webContents.debugger`(协议版本 1.3),使用的域: + - `DOM.getDocument / DOM.querySelector / DOM.setFileInputFiles` + - `Input.dispatchMouseEvent / Input.dispatchKeyEvent / Input.insertText` + - `Runtime.evaluate`(仅 runJs 一处) + - `Page.enable / Page.captureScreenshot / Page.handleJavaScriptDialog` + - `Network.enable / Network.getResponseBody`(网页监听) + - **全部是标准 CDP 域,Edge / Chrome(同为 Chromium)原生支持,命令层零改动。** +- **目录字段已预留**:`WebBrowser_Create / V2 / V3` 均有 `type`(浏览器类型,默认 chrome)、`isRemote`(是否远程)、`ipAddress`、`port`(默认 61619)、`arguments`(携带参数)、`openNewTab` 字段——**但实现不读取**,无论填什么一律打开内置窗口。 + +### 1.2 关键约束 + +| 约束 | 说明 | +|---|---| +| Electron 31 主进程 = Node 20.x | 无稳定原生 WebSocket 客户端,连接外部调试端口需引入 `ws` | +| 用户已开着的浏览器 | 未带调试端口启动的实例无法连接(Chromium 安全限制),必须带参重启 | +| 内置窗口独有 API | `webContents.debugger` 仅 Electron 可用;`Page.captureScreenshot` 外部浏览器同样可用(证据截图无障碍) | +| 元素拾取器 | 现拾取链路绑定内置窗口,外部浏览器需改走 CDP DOM 快照 | + +--- + +## 二、总体设计 + +**核心思路:传输层抽象,一次替换,上层不动。** + +``` +BrowserLike(接口不变) + ▼ +BrowserSession(cdp() 委托给 transport,其余逻辑不变) + ▼ +CdpTransport 接口(新增) + ├── EmbeddedTransport —— 现有 webContents.debugger 封装(内置浏览器,默认) + └── ExternalTransport —— WebSocket 直连外部浏览器调试端口(新增,依赖 ws) +``` + +- `BrowserLike` 接口 33 个方法、上层 169 个网页组件**全部零改动**。 +- 内置浏览器保留:`type` 未填或填内置类型时走原路径,行为与现在完全一致(回归风险隔离)。 + +### 浏览器类型枚举约定 + +| type 值 | 行为 | +|---|---| +| `chrome` | 探测本机 Chrome 路径 → 带调试端口启动 → CDP 接管 | +| `edge` | 探测本机 Edge 路径 → 同上 | +| `chrome_remote` / `edge_remote`(或 isRemote=1) | 不启动进程,直接连 `ipAddress:port` 上已监听调试端口的实例 | +| `zexplorer` / 缺省 | 内置运行浏览器(现状行为,兜底) | + +--- + +## 三、分阶段实施 + +### 阶段一:传输层抽象 + 外部浏览器连接(核心,约 2~3 天) + +**新增 `src/main/browser/transport.ts`** + +```ts +export interface CdpTransport { + send(method: string, params?: Record): Promise; + on(event: string, handler: (params: unknown) => void): void; // Page.loadEventFired 等 + close(): Promise; + kind: "embedded" | "external"; +} +``` + +- `EmbeddedTransport`:包装现有 `webContents.debugger` 的 `send/on`(`session.ts` 现有 `cdp()` 逻辑原样迁入)。 +- `ExternalTransport`:基于 `ws` 包,连接 `http://ip:port` 的调试端点: + 1. `GET /json/list` 枚举页面级 target,取 `webSocketDebuggerUrl`(或 `PUT /json/new?url=` 开新 Tab) + 2. 建立WebSocket,消息按 CDP 格式封包(`{id, method, params}` / 响应 `{id, result|error}` / 事件 `{method, params}`),promise map 按 id 配对 + 3. 断线检测(ws close 事件)→ 抛 `browser_disconnected`,带「请带调试端口重启浏览器」提示 + +**新增 `src/main/browser/external.ts`(外部浏览器进程管理)** + +- 路径探测(按优先级): + - Chrome:注册表 `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe` → `C:\Program Files\Google\Chrome\Application\chrome.exe` → `C:\Program Files (x86)\...` + - Edge:`HKLM\...\App Paths\msedge.exe` → `C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe`(Win10/11 必装,探测成功率 100%) +- 启动:`child_process.spawn(browserPath, [--remote-debugging-port=, --user-data-dir=<独立目录>, url, ...args])` + - **必须带独立 `--user-data-dir`**:否则端口参数会被用户已开的实例吞掉(连不上且无报错的头号坑) +- 端口管理:默认取 `port` 字段(61619),被占用时自增重试;启动后轮询 `GET /json/version`(超时 15s)确认就绪 +- 进程生命周期:记录 child process 引用;流程 `WebBrowser_Close` 时可选关闭(目录可加「执行后是否关闭浏览器」字段) + +**改造 `src/main/browser/session.ts`** + +- `BrowserSession` 构造函数改为接收 `CdpTransport`;`cdp()` 方法改为一行委托 `this.transport.send()` +- 单例键从 `__rpaBrowserWindow` 扩展为「按 (type, user-data/port) 维度」的会话缓存,支持同流程混合使用内置 + 外部 +- `close()`:embedded 关窗口(现状逻辑);external 断开 ws,按配置决定是否杀浏览器进程 + +### 阶段二:「打开网页」组件参数生效(约 1 天) + +**改造 `src/main/capabilities/browser.ts` 的 `WebBrowser_Create / V2 / V3 / CreateBackground`:** + +``` +type + isRemote + ipAddress + port + arguments ──→ BrowserSessionFactory + ├─ type ∈ {chrome, edge} 且 isRemote=0 → external.ts 启动并连接 + ├─ isRemote=1 → 直连 ipAddress:port + └─ 其余 → 内置浏览器(现状) +``` + +- `waitingload`(等待页面加载完成):external 模式下监听 `Page.loadEventFired` 实现,语义与内置一致 +- `arguments`(携带参数):透传给浏览器启动命令行 +- 日志明确输出实际使用的浏览器(如「已使用 Edge (127.0.0.1:9222) 打开 https://...」),便于客户排查 + +### 阶段三:多标签页管理(约 2~3 天) + +现状单 Tab 模型在外部浏览器不成立,需引入 `Target` 域: + +- `Target.getTargets` / `Target.attachToTarget`(flatten 模式)/ `Target.closeTarget` +- 落地组件:`WebBrowser_GetTAB_V1`(按标题/URL 匹配 Tab,type 字段终于生效)、`WebBrowser_Switch`(切换当前操作的 Tab)、`CreateV3` 的 `openNewTab` +- 实现方式:ExternalTransport 按 targetId 维护多个 ws 会话,BrowserSession 增加 `switchTab(targetId)`;内置浏览器对应到多个 `webContents` + +### 阶段四:拾取器与高级组件适配(约 2~3 天) + +- 元素拾取:外部浏览器场景拾取请求走 CDP `DOMSnapshot.captureSnapshot` / `DOM.getDocument` 生成候选元素树(内置窗口继续走现有拾取链路) +- 逐组件验证(阶段一~三完成后自然可用,重点回归):`WebBrowser_GetCookieNew`、`WebBrowser_StartWeblistening / GetWeblistening`(Network 域,外部同样可用)、`WebBrowser_BatchDataCrawlNew`(browserType 字段接阶段二同一工厂)、`WebBrowser_SaveAs`(`DOM.setFileInputFiles` + `Page.handleJavaScriptDialog`) +- `Element_*` 系列 100+ 组件无需改动,靠接口层自动生效 + +--- + +## 四、依赖与风险 + +### 新增依赖 + +| 包 | 大小 | 用途 | 风险 | +|---|---|---|---| +| `ws` | ~50KB 纯 JS | 连接外部浏览器调试端口(Node 20 无原生 WebSocket) | 无原生编译,供应链风险低 | + +**客户机器零安装**:Edge/Chrome 原生支持 CDP,不装任何扩展插件。 + +### 风险与对策 + +| 风险 | 对策 | +|---|---| +| 用户已开浏览器实例吞掉端口参数(连不上且无提示) | 必带独立 `--user-data-dir` 启动;文档注明该限制 | +| 调试端口与客户其他软件冲突 | 端口自增探测 + `/json/version` 就绪轮询 | +| 外部浏览器被客户手动关闭,流程中途断连 | ws close 事件 → 明确报错「浏览器已关闭」,错误路由可接重开逻辑 | +| 页面弹窗阻塞 CDP | `Page.handleJavaScriptDialog` 与内置行为一致,现有组件已覆盖 | +| 混用内置/外部时上下文串流 | 会话缓存按 (type, port) 隔离;`RunContext.browser` 生命周期不变 | +| URL 安全 | 沿用现有 `assertUrl`(仅 http/https)白名单逻辑,外部连接的 ip:port 另做私网地址校验 | + +--- + +## 五、工作量与排期估算 + +| 阶段 | 内容 | 预估 | +|---|---|---| +| 一 | 传输层抽象 + 外部连接 + 进程管理 | 2~3 天 | +| 二 | 打开网页组件参数生效 | 1 天 | +| 三 | 多标签 Target 域 | 2~3 天 | +| 四 | 拾取器适配 + 高级组件回归 | 2~3 天 | +| 测试 | 与同事网页自动化测试联动,双浏览器(Edge/Chrome)× 关键链路 | 1~2 天 | +| **合计** | | **约 1.5~2 周** | + +阶段一+二完成后即可向客户交付「用 Edge/Chrome 打开并执行基础网页自动化」;阶段三、四为完整体验补齐,可穿插进行。 + +--- + +## 六、测试计划(验收标准) + +1. **连接**:Edge / Chrome 各验证——全新启动、已装非默认路径(注册表探测)、isRemote 远程连接、端口占用自增 +2. **基础链路**:打开网页 → 点击 → 输入 → 取文本 → 截图 → 关闭,双浏览器全通过 +3. **多标签**:开 3 个 Tab → GetTAB 按标题匹配 → 切换 → 各 Tab 独立操作 → closeTarget +4. **兜底**:type 缺省/填 zexplorer 时行为与现版本完全一致(内置浏览器回归) +5. **异常**:浏览器中途被手动关、URL 非法、调试端口不通、页面弹窗 +6. **数据采集/监听**:BatchDataCrawlNew、StartWeblistening 在外部浏览器下与内置结果一致 diff --git a/docs/图像与OCR问题检测报告.md b/docs/图像与OCR问题检测报告.md new file mode 100644 index 0000000..244af9b --- /dev/null +++ b/docs/图像与OCR问题检测报告.md @@ -0,0 +1,99 @@ +# 图像与OCR 能力问题检测报告 + +- **检测时间**:2026-10-30 10:00–10:10 +- **检测人**:刘爱家(AI 辅助静态核对 + 真实运行验证) +- **检测范围**:图像与OCR 分组已接入的全部 10 项能力(Image 7 项 + OcrOffline 3 项) +- **检测方式**: + 1. 静态核对——组件目录(catalog.json)字段定义 vs 实现代码(vision.ts / ocr-caps.ts)逐键比对 + 2. 真实运行——GDI 生成含中英文的测试图、真实截屏、模板匹配直测、OCR 引擎实识别(飞桨 @gutenye/ocr-node) +- **说明**:本次仅检测未修改任何代码,问题留给同事修复。 + +--- + +## 一、结论概览 + +| 组件 | 名称 | 可用性 | 核心问题 | +|---|---|---|---| +| Image_Screenshot | 屏幕截图 | ⚠️ 基本可用 | 截图范围/图像来源字段无效,仅支持全屏 | +| Image_Find | 查找图像 | ✅ 可用 | accuracy2 字段无效 | +| Image_Find_V1 | 查找图像 | ⚠️ 有隐患 | window_obj 必填但被忽略,实际全屏查找 | +| Image_Click | 点击图像 | ⚠️ 有缺陷 | position(点击位置)被忽略,永远点中心 | +| Image_Click_V1 | 点击图像 | ⚠️ 有缺陷 | 同上 | +| Image_Click_V2 | 点击图像 | ⚠️ 有缺陷 | 同上 | +| Image_Click_V3 | 鼠标点击图像 | ❌ 功能缺失 | click_key(左/右键)被忽略,右键点击无法实现;窗口限定无效 | +| OcrOffline_GetOcrTextNew | 图像OCR识别 | ❌ 不可用 | **按目录配置无法传入图片路径,任何填写方式都报「图片路径为空」** | +| OcrOffline_GetOcrTextPosition | 图像OCR识别(含坐标) | ✅ 可用 | 无阻塞性问题 | +| OcrOffline_GetOcrTable | 获取表格数据(OCR) | ⚠️ 部分可用 | 保存至数据表/Excel 的 4 个字段全部无效;返回扁平文本非表格结构 | + +**正常项(已实测确认)**:截屏产物正确;模板匹配精度高(自测得分 0.9993);OCR 中文/数字识别准确、坐标与置信度返回正常;找图失败时报错提示合理。 + +--- + +## 二、问题清单(按严重度) + +### P0-1 OcrOffline_GetOcrTextNew:目录字段与实现完全脱节,组件不可用 + +- **现象**:该组件在目录里唯一的图片相关字段是 `uiSelector`(目标,非必填),而实现读取图片路径的键列表为 `image_path/imagePath/file_path/filePath/path/image/pictureSource`——**不包含 uiSelector**。 +- **后果**:用户按目录界面填参数,无论怎么填都无法把图片传进实现,必然报「图片路径为空」。已实测复现:`{ uiSelector: "D:/.../ocr_input.png" }` → `RuntimeError: 图片路径为空`。 +- **修复建议**:实现读取键中加入 `uiSelector`,或目录为该组件增加 `image_path` 必填字段(与 GetOcrTextPosition 保持一致)。 + +### P1-1 Image_Click 全 4 版本:忽略「点击位置」字段 + +- **现象**:目录字段 `position`(点击位置,默认 center),实现拿到匹配区域后**固定点击中心点**(`vision.ts` 中 `x + width/2, y + height/2`),position 值从不读取。 +- **后果**:用户选择「左上角/右上角/自定义偏移」等位置时静默失效,点击落点与预期不符且无任何提示。 + +### P1-2 Image_Click_V3:忽略「鼠标按键」字段,右键点击无法实现 + +- **现象**:目录字段 `click_key`(鼠标按键,默认 left),实现写死左键单击(`mouse_event(2,...)`按下 + `mouse_event(4,...)`弹起)。 +- **后果**:右键菜单类自动化场景完全无法实现,且不报错(静默按左键执行)。 + +### P1-3 OcrOffline_GetOcrTable:输出相关 4 个字段全部无效 + +- **现象**:目录字段 `showTable`(保存至数据表)、`isExcel`(保存至Excel)、`excelName`、`excelPath`,实现全部不读取;仅返回按 y 坐标排序的**扁平文本行数组**。 +- **后果**:「将结果保存至数据表/Excel」的承诺静默不执行;且行内多列文本不拆分(如「鼠标 20」作为一整行文本返回),拿不到结构化表格。 + +### P2-1 Image_Find_V1 / Image_Click_V3:「窗口对象」必填但被忽略 + +- **现象**:目录要求 `window_obj` 必填、`find_range`(图像查找范围)可选,实现两者都不读取,永远全屏截图查找。 +- **后果**:用户以为限定了窗口范围,实际全屏找图——找到错误目标(其他窗口的同款图标)或找不到时无从排查。且 window_obj 必填迫使用户必须配置一个无意义的参数。 + +### P2-2 Image_Screenshot:「截图范围」「图像来源」字段无效 + +- **现象**:目录字段 `pictureSource`(图像来源)、`shotRange`(截图范围),实现不读取,固定全屏(`PrimaryScreen`)。目录里还有 `action/position/position_img` 三个疑似遗留占位字段。 +- **后果**:只支持全屏截图;另外多显示器环境只截主屏,副屏内容丢失。 + +### P3-1 「特征相似度」accuracy2 字段无效 + +- Image_Find / Image_Click / Image_Click_V1 的 `accuracy2`(特征相似度)字段,实现不读取。当前实现是纯像素差模板匹配,没有特征匹配层,该字段为冗余配置。 + +### P3-2 「展示鼠标移动轨迹」show_track 字段无效 + +- Image_Click 全版本的 `show_track` 被忽略。当前实现 SetCursorPos 直接跳转,无轨迹动画。低优先级。 + +### P3-3 其他小问题 + +- Image_Screenshot 当 `image_path` 为空时输出文件落盘到进程工作目录(不可预期);因该字段必填,实际风险低,建议加显式校验。 +- OcrOffline_GetOcrTable 的行排序仅按 y 坐标,同一行内多个文本块不合并、不按 x 排序,多列场景顺序不稳定。 + +--- + +## 三、实测验证记录(正常部分证据) + +| 测试 | 结果 | +|---|---| +| GDI 生成测试图(600×200,含「RPA流程测试ABC123」「名称 数量」「鼠标 20」) | 成功 | +| Image_Screenshot 截屏逻辑(同款 PowerShell 脚本) | 成功,产物 246KB PNG | +| findImage 模板匹配(app_logo_tpl 模板 vs 实时截屏) | 命中 (93,27),得分 **0.9993** | +| OcrOffline_GetOcrTextNew(传入 image_path 时) | 识别结果 `RPA流程测试ABC123\n名称 数量\n鼠标 20` 完全正确 | +| OcrOffline_GetOcrTextPosition | 3 行文本坐标 + 置信度(0.999/0.949/0.899)返回正常 | +| 找图失败路径 | 正确抛「屏幕中未找到目标图像」并附建议(注:vitest 环境内 PowerShell 受 PATH 限制报 ENOENT,真实应用主进程不受影响) | + +**说明**:OCR 引擎(飞桨)与模板匹配算法本身质量良好,问题集中在**目录字段与实现的键名/功能脱节**——与此前 Office、数据处理组发现的拼接列、单元格格式问题是同一类「接入只对了一半」的缺陷。 + +--- + +## 四、给同事的修复优先级建议 + +1. **先修 P0-1**(GetOcrTextNew 传图键)——1 行改动,组件从不可用变可用 +2. **再修 P1 三项**(Click 的 position/click_key、GetOcrTable 的输出字段)——都是读键 + 少量分支逻辑 +3. **P2/P3 可排后**——窗口限定、截图范围属于功能增强,当前行为可接受但建议在 UI 上隐藏无效字段避免误导 diff --git a/resources/desktop-picker.ps1 b/resources/desktop-picker.ps1 index 87a8b93..c88696e 100644 --- a/resources/desktop-picker.ps1 +++ b/resources/desktop-picker.ps1 @@ -1,6 +1,6 @@ param([string]$Mode = "element") $ErrorActionPreference = "Continue" -Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes +Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes, WindowsBase Add-Type -AssemblyName System.Windows.Forms Add-Type -MemberDefinition "[DllImport(`"user32.dll`")] public static extern short GetAsyncKeyState(int key); [DllImport(`"user32.dll`", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr h, System.Text.StringBuilder sb, int max);" -Name PickW32 -Namespace RpaPick | Out-Null @@ -15,18 +15,53 @@ if ($Mode -eq "windows") { exit 0 } +# ===== 信息条(屏幕顶部居中,显眼) ===== +$info = New-Object System.Windows.Forms.Form +$info.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None +$info.TopMost = $true +$info.ShowInTaskbar = $false +$info.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual +$sw = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds.Width +$info.Location = New-Object System.Drawing.Point(([int]($sw / 2) - 280), 10) +$info.Size = New-Object System.Drawing.Size(560, 64) +$info.BackColor = [System.Drawing.Color]::FromArgb(15, 40, 90) +$info.Opacity = 0.92 +$infoLabel = New-Object System.Windows.Forms.Label +$infoLabel.Dock = [System.Windows.Forms.DockStyle]::Fill +$infoLabel.ForeColor = [System.Drawing.Color]::White +$infoLabel.Font = New-Object System.Drawing.Font("Microsoft YaHei", 11) +$infoLabel.TextAlign = [System.Drawing.ContentAlignment]::MiddleCenter +$infoLabel.Text = "移动鼠标到目标元素上" +$info.Controls.Add($infoLabel) + +# ===== 高亮框(半透明红块跟随) ===== +$hl = New-Object System.Windows.Forms.Form +$hl.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None +$hl.TopMost = $true +$hl.ShowInTaskbar = $false +$hl.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual +$hl.BackColor = [System.Drawing.Color]::Red +$hl.Opacity = 0.35 + +function Set-HighlightBox([int]$x, [int]$y, [int]$w, [int]$h) { + $hl.Location = New-Object System.Drawing.Point(($x - 2), ($y - 2)) + $hl.Size = New-Object System.Drawing.Size(($w + 4), ($h + 4)) + $hl.Show() +} + +# ===== 下钻到包含点的最小后代 ===== function Get-DeepestAtPoint([double]$px, [double]$py) { $point = New-Object System.Windows.Automation.Point($px, $py) $cur = [System.Windows.Automation.AutomationElement]::FromPoint($point) if (-not $cur) { return $null } - for ($depth = 0; $depth -lt 30; $depth++) { + for ($depth = 0; $depth -lt 25; $depth++) { $children = $cur.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition) $next = $null $nextArea = [double]::MaxValue foreach ($c in $children) { try { $r = $c.Current.BoundingRectangle - if ($r.Width -gt 0 -and $r.Height -gt 0 -and $px -ge $r.X -and $px -le ($r.X + $r.Width) -and $py -ge $r.Y -and $py -le ($r.Y + $r.Height)) { + if ($r.Width -gt 1 -and $r.Height -gt 1 -and $px -ge $r.X -and $px -le ($r.X + $r.Width) -and $py -ge $r.Y -and $py -le ($r.Y + $r.Height)) { $area = $r.Width * $r.Height if ($area -lt $nextArea) { $next = $c; $nextArea = $area } } @@ -38,56 +73,76 @@ function Get-DeepestAtPoint([double]$px, [double]$py) { return $cur } -$form = New-Object System.Windows.Forms.Form -$form.FormBorderStyle = [System.Windows.Forms.FormBorderStyle]::None -$form.TopMost = $true -$form.StartPosition = [System.Windows.Forms.FormStartPosition]::Manual -$form.Location = New-Object System.Drawing.Point(20, 20) -$form.Size = New-Object System.Drawing.Size(460, 130) -$form.BackColor = [System.Drawing.Color]::FromArgb(20, 60, 120) -$label = New-Object System.Windows.Forms.Label -$label.Dock = [System.Windows.Forms.DockStyle]::Fill -$label.ForeColor = [System.Drawing.Color]::White -$label.Font = New-Object System.Drawing.Font("Microsoft YaHei", 11) -$label.Text = "移动鼠标选择元素..." -$form.Controls.Add($label) -$form.Show() +$info.Show() +$hl.Show() + +$stage = "hover" +$chain = New-Object System.Collections.ArrayList +$selIdx = -1 +$prevSpace = $false +$prevPgUp = $false +$prevPgDn = $false -$prevDown = $false -$result = $null while ($true) { - $ptX = [System.Windows.Forms.Cursor]::Position.X - $ptY = [System.Windows.Forms.Cursor]::Position.Y - $el = Get-DeepestAtPoint $ptX $ptY - $down = ([RpaPick.PickW32]::GetAsyncKeyState(32) -band 0x8000) -ne 0 + $space = ([RpaPick.PickW32]::GetAsyncKeyState(32) -band 0x8000) -ne 0 $esc = ([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8000) -ne 0 - if ($esc) { break } - if ($el) { - $c = $el.Current - $ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") - $label.Text = "名称: " + $c.Name + [Environment]::NewLine + "类型: " + $ctrlName + [Environment]::NewLine + "空格=拾取 Esc=取消" - } - if ($down -and $el) { - $c = $el.Current - $rootName = "" - $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker - $anc = $walker.GetParent($el) - while ($anc -and -not $anc.Equals([System.Windows.Automation.AutomationElement]::RootElement)) { - $h = $anc.Current.NativeWindowHandle - if ($h -ne 0) { - $sb = New-Object System.Text.StringBuilder 512 - [RpaPick.PickW32]::GetWindowTextW([IntPtr]$h, $sb, 512) | Out-Null - if ($sb.ToString()) { $rootName = $sb.ToString(); break } - } - $anc = $walker.GetParent($anc) - } - $ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") - $result = @{ window = $rootName; name = $c.Name; automationId = $c.AutomationId; controlType = $ctrlName; className = $c.ClassName } - break - } - $prevDown = $down - Start-Sleep -Milliseconds 80 -} -$form.Close() + $pgUp = ([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8000) -ne 0 + $pgDn = ([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8000) -ne 0 -if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) } + if ($esc) { break } + + if ($stage -eq "hover") { + $ptX = [System.Windows.Forms.Cursor]::Position.X + $ptY = [System.Windows.Forms.Cursor]::Position.Y + $el = Get-DeepestAtPoint $ptX $ptY + if ($el) { + $c = $el.Current + $ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") + Set-HighlightBox $ptX $ptY 1 1 + $r = $c.Current.BoundingRectangle + if ($r.Width -gt 0) { Set-HighlightBox ([int]$r.X) ([int]$r.Y) ([int]$r.Width) ([int]$r.Height) } + $infoLabel.Text = "名称: " + $c.Name + " 类型: " + $ctrlName + " [空格]锁定 [Esc]取消" + } + if ($space -and -not $prevSpace -and $el) { + $stage = "locked" + $chain = New-Object System.Collections.ArrayList + $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker + $cur = $el + while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) { + $c = $cur.Current + $r = $c.BoundingRectangle + [void]$chain.Insert(0, @{ el = $cur; name = [string]$c.Name; automationId = [string]$c.AutomationId; controlType = $c.ControlType.ProgrammaticName.Replace("ControlType.", ""); x = [int]$r.X; y = [int]$r.Y; w = [int]$r.Width; h = [int]$r.Height }) + $cur = $walker.GetParent($cur) + } + $selIdx = $chain.Count - 1 + $infoLabel.Text = "已锁定 [" + ($selIdx + 1) + "/" + $chain.Count + "] [PageUp/PageDown]调层级 [空格]确认回填 [Esc]取消" + } + } + + if ($stage -eq "locked") { + $item = $chain[$selIdx] + if ($item) { + Set-HighlightBox $item.x $item.y $item.w $item.h + $infoLabel.Text = "层级 " + ($selIdx + 1) + "/" + $chain.Count + " 名称: " + $item.name + " 类型: " + $item.controlType + " [PageUp/PageDown]调层级 [空格]确认 [Esc]取消" + } + if ($pgUp -and -not $prevPgUp -and $selIdx -gt 0) { $selIdx -= 1 } + if ($pgDn -and -not $prevPgDn -and $selIdx -lt ($chain.Count - 1)) { $selIdx += 1 } + if ($space -and -not $prevSpace) { + $item = $chain[$selIdx] + $rootTitle = "" + foreach ($it in $chain) { if ($it.name) { $rootTitle = $it.name; break } } + $result = @{ selected = $selIdx; window = $rootTitle; name = $item.name; automationId = $item.automationId; controlType = $item.controlType; x = $item.x; y = $item.y; w = $item.w; h = $item.h } + break + } + } + + $prevSpace = $space + $prevPgUp = $pgUp + $prevPgDn = $pgDn + Start-Sleep -Milliseconds 60 +} + +$info.Close() +$hl.Close() + +if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) } \ No newline at end of file diff --git a/src/main/capabilities/files.ts b/src/main/capabilities/files.ts index 6ca2641..31387da 100644 --- a/src/main/capabilities/files.ts +++ b/src/main/capabilities/files.ts @@ -59,7 +59,10 @@ register("File_Write", async (_ctx, node, args) => { }); register(["File_CreateFile"], async (_ctx, node, args) => { - const filePath = requirePath(pick(args, ["path", "file", "file_path"]), "文件路径", node.id, node.componentId); + // catalog 字段为 folder_path + name(组合成完整路径),兼容旧键 path + const base = pick(args, ["folder_path", "path", "file", "file_path"]); + const name = pick(args, ["name"]); + const filePath = requirePath(name ? path.join(base, name) : base, "文件路径", node.id, node.componentId); await fs.mkdir(path.dirname(filePath), { recursive: true }); await fs.writeFile(filePath, "", { flag: "a" }); return filePath; @@ -80,8 +83,15 @@ register("File_Copy", async (_ctx, node, args) => { }); register(["File_File_Move", "File_Move"], async (_ctx, node, args) => { - const source = requirePath(pick(args, ["pathSrc", "src", "source"]), "源路径", node.id, node.componentId); - const target = requirePath(pick(args, ["pathDest", "dest", "target"]), "目标路径", node.id, node.componentId); + // File_Move 的 catalog 字段为 file_type + file_path/folder_path + target_path(+name),与 File_File_Move 的 pathSrc/pathDest 兼容 + const isFolder = String(args.file_type ?? "file") === "folder"; + const source = requirePath(pick(args, ["pathSrc", isFolder ? "folder_path" : "file_path", "src", "source"]), "源路径", node.id, node.componentId); + let target = pick(args, ["pathDest", "dest"]); + if (!target) { + const destDir = pick(args, ["target_path"]); + const newName = truthy(args.rename) ? pick(args, ["name"]) : ""; + target = requirePath(newName ? path.join(destDir, newName) : destDir, "目的地路径", node.id, node.componentId); + } await fs.mkdir(path.dirname(target), { recursive: true }); try { await fs.rename(source, target); @@ -152,7 +162,8 @@ register(["File_PathSize", "File_PathSizeNew"], async (_ctx, node, args) => { }); register("File_GetFileInfo", async (_ctx, node, args) => { - const target = requirePath(pick(args, ["path", "file"]), "路径", node.id, node.componentId); + // catalog 字段为 file_path,兼容 path/file + const target = requirePath(pick(args, ["file_path", "path", "file"]), "路径", node.id, node.componentId); const stat = await fs.stat(target); return { path: target, @@ -165,7 +176,8 @@ register("File_GetFileInfo", async (_ctx, node, args) => { }); register("File_WaitFile", async (ctx, node, args) => { - const target = requirePath(pick(args, ["path", "file"]), "文件路径", node.id, node.componentId); + // catalog 字段为 file_path,兼容 path/file + const target = requirePath(pick(args, ["file_path", "path", "file"]), "文件路径", node.id, node.componentId); const timeout = Math.max(1, Number(args.time_out ?? args.timeout ?? 30)) * 1000; const started = Date.now(); while (Date.now() - started < timeout) { @@ -185,7 +197,8 @@ register("File_WaitFile", async (ctx, node, args) => { }); register("File_CompressFile", async (_ctx, node, args) => { - const source = requirePath(pick(args, ["pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId); + // catalog 字段为 file_path(文件模式)/folder_path(文件夹模式)+ out_path,兼容旧键 + const source = requirePath(pick(args, ["file_path", "pathSrc", "path", "folder_path"]), "源路径", node.id, node.componentId); const target = requirePath(pick(args, ["pathDest", "zip_path", "out_path"]), "压缩包路径", node.id, node.componentId); const zip = new AdmZip(); const stat = await fs.stat(source); diff --git a/src/main/capabilities/ftp.ts b/src/main/capabilities/ftp.ts index d707fcd..e5a6012 100644 --- a/src/main/capabilities/ftp.ts +++ b/src/main/capabilities/ftp.ts @@ -111,7 +111,9 @@ register("FTP_CreateDir", async (ctx, node, args) => { const client = await requireClient(node.id); const dir = requireText(args, ["dirname"], "目录名", node.id); await client.ensureDir(dir); - client.cd(dir).catch(() => {}); + // 创建后进入该目录;必须 await,否则下个节点的命令与本次 cd 并发, + // basic-ftp 会以 "task while another one is still running" 关闭客户端 + await client.cd(dir).catch(() => {}); ctx.log("已创建目录:" + dir, "info", node.id); return { created: true, dir }; }); diff --git a/src/main/capabilities/windows-system.ts b/src/main/capabilities/windows-system.ts index 0760bb5..d611206 100644 --- a/src/main/capabilities/windows-system.ts +++ b/src/main/capabilities/windows-system.ts @@ -87,6 +87,12 @@ register("System_SetInputMethodState", async (ctx, node, args) => { register("File_OpenFile", async (ctx, node, args) => { const p = reqText(args, ["path"], "文件夹路径", node.id); - await run("explorer.exe", [p], { windowsHide: true }); + try { + await run("explorer.exe", [p], { windowsHide: true }); + } catch (error) { + // explorer.exe 打开成功也会返回退出码 1(Windows 已知行为),仅路径不存在时才是真失败 + const code = (error as { code?: unknown }).code; + if (code !== 1) throw error; + } return { opened: true, path: p }; }); diff --git a/src/renderer/src/validation/flowValidation.ts b/src/renderer/src/validation/flowValidation.ts index ab5d142..add8a85 100644 --- a/src/renderer/src/validation/flowValidation.ts +++ b/src/renderer/src/validation/flowValidation.ts @@ -56,7 +56,7 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida } const definedVars = new Set([ - ...flow.variables.map((item) => item.name), + ...(flow.variables ?? []).map((item) => item.name), ...flow.nodes.map((node) => node.options?.outputVar).filter(Boolean) as string[] ]); const variableRef = /\$\{\s*([A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*)\s*(?:\.[A-Za-z_]\w*)?\s*\}/g;