This commit is contained in:
yu 2026-10-30 10:52:41 +08:00
commit a25f8c76b6
8 changed files with 442 additions and 61 deletions

View File

@ -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 暴露深度调优

View File

@ -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<T>(method: string, params?: Record<string, unknown>): Promise<T>;
on(event: string, handler: (params: unknown) => void): void; // Page.loadEventFired 等
close(): Promise<void>;
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=<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 在外部浏览器下与内置结果一致

View File

@ -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 上隐藏无效字段避免误导

View File

@ -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) }

View File

@ -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);

View File

@ -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 };
});

View File

@ -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 };
});

View File

@ -56,7 +56,7 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida
}
const definedVars = new Set<string>([
...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;