From a4b7e24acdcdea850f489a246f6401db98008c09 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 09:58:42 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E6=8B=BE?= =?UTF-8?q?=E5=8F=96=20v2=E2=80=94=E2=80=94=E4=B8=A4=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=EF=BC=88=E6=82=AC=E5=81=9C=E9=80=89=E7=9B=AE?= =?UTF-8?q?=E6=A0=87/=E5=BE=AE=E8=B0=83=E6=A8=A1=E5=BC=8F=EF=BC=89?= =?UTF-8?q?=E3=80=81PageUp/PageDown=20=E5=B1=82=E7=BA=A7=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E3=80=81=E9=AB=98=E4=BA=AE=E6=A1=86=E8=B7=9F=E9=9A=8F=E3=80=81?= =?UTF-8?q?UTF8=20BOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/desktop-picker.ps1 | 147 +++++++++++++++++++++-------------- 1 file changed, 90 insertions(+), 57 deletions(-) diff --git a/resources/desktop-picker.ps1 b/resources/desktop-picker.ps1 index 87a8b93..cafe81b 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,79 +15,112 @@ if ($Mode -eq "windows") { exit 0 } -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++) { - $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)) { - $area = $r.Width * $r.Height - if ($area -lt $nextArea) { $next = $c; $nextArea = $area } - } - } catch {} - } - if (-not $next) { break } - $cur = $next - } - 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.Size = New-Object System.Drawing.Size(480, 110) $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 = "移动鼠标选择元素..." +$label.Font = New-Object System.Drawing.Font("Microsoft YaHei", 10) $form.Controls.Add($label) -$form.Show() -$prevDown = $false -$result = $null +# ---- 高亮窗(半透明红块) ---- +$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 + +# ---- 状态 ---- +$stage = "hover" +$chain = New-Object System.Collections.ArrayList +$selIdx = -1 +$prevPageUp = $false +$prevPageDown = $false +$prevSpace = $false + +function Show-Element($el) { + if ($null -eq $el) { $script:hl.Hide(); return } + $r = $el.Current.BoundingRectangle + if ($r.Width -le 0 -or $r.Height -le 0) { $script:hl.Hide(); return } + $script:hl.Location = New-Object System.Drawing.Point([int]($r.X - 2), [int]($r.Y - 2)) + $script:hl.Size = New-Object System.Drawing.Size([int]($r.Width + 4), [int]($r.Height + 4)) + $script:hl.Show() +} + +$form.Show() +$hl.Show() + 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 + $pgUp = ([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8000) -ne 0 + $pgDn = ([RpaPick.PickW32]::GetAsyncKeyState(34) -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) + + if ($stage -eq "hover") { + $ptX = [System.Windows.Forms.Cursor]::Position.X + $ptY = [System.Windows.Forms.Cursor]::Position.Y + $point = New-Object System.Windows.Automation.Point($ptX, $ptY) + $el = $null + try { $el = [System.Windows.Automation.AutomationElement]::FromPoint($point) } catch {} + if ($el) { + $c = $el.Current + $ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") + $label.Text = "名称: " + $c.Name + [Environment]::NewLine + "类型: " + $ctrlName + [Environment]::NewLine + "空格=锁定 Esc=取消" + Show-Element $el + } + if ($space -and -not $prevSpace -and $el) { + # 进入微调:收集从根到命中的元素链 + $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker + $chain.Clear() + $cur = $el + while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) { + $cc = $cur.Current + $rr = $cc.BoundingRectangle + [void]$chain.Insert(0, @{ el = $cur; name = [string]$cc.Name; automationId = [string]$cc.AutomationId; controlType = $cc.ControlType.ProgrammaticName.Replace("ControlType.", ""); x = [int]$rr.X; y = [int]$rr.Y; w = [int]$rr.Width; h = [int]$rr.Height }) + $cur = $walker.GetParent($cur) + } + $selIdx = $chain.Count - 1 + $stage = "adjust" + $label.Text = "微调模式:PageUp/PageDown 切换层级,空格确认,Esc 取消" } - $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 + + if ($stage -eq "adjust") { + if ($pgUp -and -not $prevPageUp -and $selIdx -gt 0) { $selIdx -= 1 } + if ($pgDn -and -not $prevPageDown -and $selIdx -lt ($chain.Count - 1)) { $selIdx += 1 } + $item = $chain[$selIdx] + if ($item) { + $label.Text = "层级 " + ($selIdx + 1) + "/" + $chain.Count + " 名称: " + $item.name + [Environment]::NewLine + "类型: " + $item.controlType + [Environment]::NewLine + "PageUp/PageDown=切层级 空格=确认 Esc=取消" + $hl.Location = New-Object System.Drawing.Point($item.x - 2, $item.y - 2) + $hl.Size = New-Object System.Drawing.Size($item.w + 4, $item.h + 4) + $hl.Show() + } + if ($space -and -not $prevSpace) { + $item = $chain[$selIdx] + # 窗口名 = 链中第一个带窗口句柄的层 + $rootName = "" + foreach ($it in $chain) { if ($it.name) { $rootName = $it.name; break } } + $result = @{ selected = $selIdx; window = $rootName; name = $item.name; automationId = $item.automationId; controlType = $item.controlType; x = $item.x; y = $item.y; w = $item.w; h = $item.h } + break + } + } + + $prevPageUp = $pgUp + $prevPageDown = $pgDn + $prevSpace = $space + Start-Sleep -Milliseconds 60 } $form.Close() +$hl.Close() if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) } From a3fbae9d832b2489a3730224cfb75ec4c5e79845 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:10:46 +0800 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=E5=9B=BE=E5=83=8F=E4=B8=8EOCR=20?= =?UTF-8?q?=E7=BB=84=2010=20=E9=A1=B9=E8=83=BD=E5=8A=9B=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E6=A3=80=E6=B5=8B=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 静态核对 + 真实运行双路验证,含 P0~P3 分级问题清单与修复优先级建议 - P0: GetOcrTextNew 目录字段无法传图(不可用);P1: Click 忽略 position/click_key、GetOcrTable 输出字段无效 - 算法层(飞桨 OCR/模板匹配 0.9993)实测正常,问题集中在目录与实现脱节 --- docs/图像与OCR问题检测报告.md | 99 ++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 docs/图像与OCR问题检测报告.md 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 上隐藏无效字段避免误导 From 19eb53b5d8148c6c4a3538f2cc1093ff8f4a46c4 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:14:22 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E7=BB=84=205=20=E5=A4=84=E7=9B=AE=E5=BD=95=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E4=B8=8E=E5=AE=9E=E7=8E=B0=E9=94=AE=E5=90=8D=E8=84=B1=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - File_CreateFile: 目录字段 folder_path/name,实现读 path → 必报文件路径为空 - File_GetFileInfo / File_WaitFile: 目录字段 file_path,实现读 path → 读不到 - File_CompressFile: 目录字段 file_path,实现读 pathSrc → 压缩源找不到 - File_Move: 目录字段 file_path/target_path/name,实现与 File_File_Move 共用 pathSrc/pathDest → 读不到;补齐别名兼容 --- src/main/capabilities/files.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) 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); From c592cd891f750691b7e472a94f08b88790359205 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:24:01 +0800 Subject: [PATCH 4/7] =?UTF-8?q?docs:=20=E5=A4=96=E6=8E=A5=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8(Edge/Chrome)=E6=94=AF=E6=8C=81=E6=94=B9?= =?UTF-8?q?=E5=8A=A8=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 传输层抽象:CdpTransport 接口隔离 webContents.debugger 与 WebSocket 两种通道,BrowserLike 接口与 169 个网页组件零改动 - 唯一新依赖 ws(~50KB);客户机器零安装(Edge/Chrome 原生 CDP) - 分四阶段:传输层+外部连接(2-3天) → 打开网页组件参数生效(1天) → 多标签 Target 域(2-3天) → 拾取器与高级组件适配(2-3天),合计约 1.5-2 周 - 关键坑已注明:独立 user-data-dir 必带、端口占用自增、断连报错 --- docs/04-外接浏览器支持改动方案.md | 186 +++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/04-外接浏览器支持改动方案.md 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 在外部浏览器下与内置结果一致 From 517d08d7ef3aa2c8d192ec2385c5e4301ce90c43 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:24:49 +0800 Subject: [PATCH 5/7] =?UTF-8?q?docs:=20=E6=A1=8C=E9=9D=A2=E6=8B=BE?= =?UTF-8?q?=E5=8F=96=E4=B8=8E=E5=AE=9A=E4=BD=8D=E2=80=94=E2=80=94=E5=8E=9F?= =?UTF-8?q?=E5=8E=82=E5=AE=9E=E7=8E=B0=E8=A7=A3=E8=AF=BB=E4=B8=8E=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E9=A1=B9=E7=99=BB=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/03-能力接入规划与排期.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/03-能力接入规划与排期.md b/docs/03-能力接入规划与排期.md index 2829667..58d10df 100644 --- a/docs/03-能力接入规划与排期.md +++ b/docs/03-能力接入规划与排期.md @@ -450,3 +450,23 @@ - 本文档为能力接入的唯一规划来源;新增能力须先在此登记阶段 - 下一步:将第一节清单固化为 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 暴露深度调优 From f048be792c879d63314ee68f14cbd46c2a01627d Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:33:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=E6=A1=8C=E9=9D=A2=E6=8B=BE?= =?UTF-8?q?=E5=8F=96=20v3=E2=80=94=E2=80=94=E9=A1=B6=E9=83=A8=E5=B1=85?= =?UTF-8?q?=E4=B8=AD=E4=BF=A1=E6=81=AF=E6=9D=A1=E3=80=81=E9=AB=98=E4=BA=AE?= =?UTF-8?q?=E6=A1=86=E8=B7=9F=E9=9A=8F=E3=80=81=E4=B8=A4=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=EF=BC=88=E9=94=81=E5=AE=9A/=E5=BE=AE?= =?UTF-8?q?=E8=B0=83=EF=BC=89=EF=BC=8C=E6=98=BE=E5=BC=8F=20UTF8=20BOM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resources/desktop-picker.ps1 | 134 ++++++++++++++++++++--------------- 1 file changed, 78 insertions(+), 56 deletions(-) diff --git a/resources/desktop-picker.ps1 b/resources/desktop-picker.ps1 index cafe81b..c88696e 100644 --- a/resources/desktop-picker.ps1 +++ b/resources/desktop-picker.ps1 @@ -15,21 +15,26 @@ if ($Mode -eq "windows") { exit 0 } -# ---- 信息窗 ---- -$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(480, 110) -$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", 10) -$form.Controls.Add($label) +# ===== 信息条(屏幕顶部居中,显眼) ===== +$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 @@ -38,25 +43,45 @@ $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 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 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 } + } + } catch {} + } + if (-not $next) { break } + $cur = $next + } + return $cur +} + +$info.Show() +$hl.Show() + $stage = "hover" $chain = New-Object System.Collections.ArrayList $selIdx = -1 -$prevPageUp = $false -$prevPageDown = $false $prevSpace = $false - -function Show-Element($el) { - if ($null -eq $el) { $script:hl.Hide(); return } - $r = $el.Current.BoundingRectangle - if ($r.Width -le 0 -or $r.Height -le 0) { $script:hl.Hide(); return } - $script:hl.Location = New-Object System.Drawing.Point([int]($r.X - 2), [int]($r.Y - 2)) - $script:hl.Size = New-Object System.Drawing.Size([int]($r.Width + 4), [int]($r.Height + 4)) - $script:hl.Show() -} - -$form.Show() -$hl.Show() +$prevPgUp = $false +$prevPgDn = $false while ($true) { $space = ([RpaPick.PickW32]::GetAsyncKeyState(32) -band 0x8000) -ne 0 @@ -69,58 +94,55 @@ while ($true) { if ($stage -eq "hover") { $ptX = [System.Windows.Forms.Cursor]::Position.X $ptY = [System.Windows.Forms.Cursor]::Position.Y - $point = New-Object System.Windows.Automation.Point($ptX, $ptY) - $el = $null - try { $el = [System.Windows.Automation.AutomationElement]::FromPoint($point) } catch {} + $el = Get-DeepestAtPoint $ptX $ptY if ($el) { $c = $el.Current $ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") - $label.Text = "名称: " + $c.Name + [Environment]::NewLine + "类型: " + $ctrlName + [Environment]::NewLine + "空格=锁定 Esc=取消" - Show-Element $el + 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 - $chain.Clear() $cur = $el while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) { - $cc = $cur.Current - $rr = $cc.BoundingRectangle - [void]$chain.Insert(0, @{ el = $cur; name = [string]$cc.Name; automationId = [string]$cc.AutomationId; controlType = $cc.ControlType.ProgrammaticName.Replace("ControlType.", ""); x = [int]$rr.X; y = [int]$rr.Y; w = [int]$rr.Width; h = [int]$rr.Height }) + $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 - $stage = "adjust" - $label.Text = "微调模式:PageUp/PageDown 切换层级,空格确认,Esc 取消" + $infoLabel.Text = "已锁定 [" + ($selIdx + 1) + "/" + $chain.Count + "] [PageUp/PageDown]调层级 [空格]确认回填 [Esc]取消" } } - if ($stage -eq "adjust") { - if ($pgUp -and -not $prevPageUp -and $selIdx -gt 0) { $selIdx -= 1 } - if ($pgDn -and -not $prevPageDown -and $selIdx -lt ($chain.Count - 1)) { $selIdx += 1 } + if ($stage -eq "locked") { $item = $chain[$selIdx] if ($item) { - $label.Text = "层级 " + ($selIdx + 1) + "/" + $chain.Count + " 名称: " + $item.name + [Environment]::NewLine + "类型: " + $item.controlType + [Environment]::NewLine + "PageUp/PageDown=切层级 空格=确认 Esc=取消" - $hl.Location = New-Object System.Drawing.Point($item.x - 2, $item.y - 2) - $hl.Size = New-Object System.Drawing.Size($item.w + 4, $item.h + 4) - $hl.Show() + 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] - # 窗口名 = 链中第一个带窗口句柄的层 - $rootName = "" - foreach ($it in $chain) { if ($it.name) { $rootName = $it.name; break } } - $result = @{ selected = $selIdx; window = $rootName; name = $item.name; automationId = $item.automationId; controlType = $item.controlType; x = $item.x; y = $item.y; w = $item.w; h = $item.h } + $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 } } - $prevPageUp = $pgUp - $prevPageDown = $pgDn $prevSpace = $space + $prevPgUp = $pgUp + $prevPgDn = $pgDn Start-Sleep -Milliseconds 60 } -$form.Close() + +$info.Close() $hl.Close() -if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) } +if ($result) { Write-Output (ConvertTo-Json $result -Compress) } else { Write-Output (ConvertTo-Json @{ cancelled = $true } -Compress) } \ No newline at end of file From bc0ed3fcd89d819d3a77648f97d4cf6816adb1b8 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Fri, 30 Oct 2026 10:40:04 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E6=96=87=E4=BB=B6/FTP=E5=AE=9E?= =?UTF-8?q?=E6=B5=8B=E4=BF=AE=E5=A4=8D=E4=B8=89=E5=A4=84=E7=9C=9FBug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - windows-system.ts: File_OpenFile 打开文件夹时 explorer.exe 成功也返回退出码1, 放行该退出码(实测验证) - ftp.ts: FTP_CreateDir 的 cd() 改为 await, 消除 basic-ftp 并发冲突(launched a task while another one is still running, 实测验证) - flowValidation.ts: flow.variables 空值防御, 避免旧流程JSON缺字段时渲染崩溃 实测: FileTest 32/32, FtpTest 10/10 全部通过 --- src/main/capabilities/ftp.ts | 4 +++- src/main/capabilities/windows-system.ts | 8 +++++++- src/renderer/src/validation/flowValidation.ts | 2 +- 3 files changed, 11 insertions(+), 3 deletions(-) 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;