Compare commits

...
Author SHA1 Message Date
yu 4fc1209c93 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 14:41:41 +08:00
yu 5b1b41663f fix: 修复远程catalog变更引入的3个测试失败(数量658→654, canonical链断→V3, 8字段缺type, 3字段widget非法→text, counts.fields 4297→4294, overrides描述与catalog同步) 2026-11-03 14:41:08 +08:00
yu 8fdc62d98d feat: 图片Base64编解码组件统一(Encode并入方向切换,Decode隐藏), 验证任务归并: 图像OCR未测任务 41->7 (Base64/CV/OCR票据卡证证照通用离线) 2026-11-03 14:28:23 +08:00
yu 4486c57536 refactor: 屏幕截图删除输出目录字段, 仅留保存路径与文件名称(旧流程输出目录值运行时仍兼容) 2026-11-03 14:17:19 +08:00
yu 04ac6130a0 feat: 屏幕截图面板精简为 范围/保存路径/输出目录/文件名称, 截图区域仅框选模式显示, 删除冗余action与position_img 2026-11-03 14:14:42 +08:00
yu 8176bf7c75 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 14:14:17 +08:00
yu 8dc2b60c6d feat: datePicker加原生日期选择器; openWindowMultipleSelect/openWindowEditor加选窗口按钮; dateRangeFilter加日期范围输入 2026-11-03 14:14:01 +08:00
yu b4bcee8c15 refactor: 屏幕截图冗余区域字段收敛(action/position_img 并入截图区域), 仅框选区域模式显示 2026-11-03 14:13:07 +08:00
yu 9e9617256d Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 14:11:52 +08:00
yu 6bf8e90c63 fix: 窗口选择器从循环confirm改为弹出列表modal, 用户可一次看到所有窗口并点击选择 2026-11-03 14:11:38 +08:00
yu 41b999115e fix: 屏幕截图保存路径字段正名(目标文件路径->截图保存路径), 备用输出目录/文件名折叠进高级参数, 描述写清保存回退链 2026-11-03 14:07:06 +08:00
yu 4a3e6e38c7 fix: overlay窗口closable:false导致close()被忽略, 改用destroy()强制销毁 2026-11-03 14:05:29 +08:00
yu b6829cbaab Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 14:01:58 +08:00
yu 0d429e5175 fix: overlay真正集成(CRLF换行导致正则替换全部失效) showRunOverlay/hideRunOverlay/overlayBroadcast实际调用, nodeLabel/total字段补上 2026-11-03 14:01:45 +08:00
yu 38b2199d4b fix: 裁剪图像脚本New-Object不能置于.NET方法参数表(组件从未跑通的根因), 改用[Type]::new() 2026-11-03 13:59:02 +08:00
yu 9bfa4ac107 fix: overlay改loadFile(不用data URL) + nodeIntegration开启 + taskbar进度条兜底 2026-11-03 13:57:49 +08:00
yu bc4b349771 fix: overlay窗口 require改ESM import, nodeIntegration开启使ipcRenderer可用, script标签模板转义修复 2026-11-03 13:55:28 +08:00
yu 0651bc995c Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 13:52:45 +08:00
yu f163c8d0b6 feat: 运行时主窗口最小化+悬浮进度窗(alwaysOnTop不可拖动/resize,显示步骤名/进度/耗时/停止按钮,结束后自动恢复主窗口) 2026-11-03 13:52:25 +08:00
yu fe0c6c2790 fix: 裁剪图像参数校验与字段说明(源图片路径/坐标语义), 空路径与无效区域给明确报错 2026-11-03 13:50:57 +08:00
yu 6b54decfb0 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 13:46:49 +08:00
yu 346e2ad342 fix: 补充用户不便输入的displayType控件(mouseRecord/clickPosition坐标拾取、excelRange文本、USB串口下拉、键鼠驱动下拉、邮箱email输入、previewDataTable只读、customModal/itemGroup JSON textarea); pickPointToField函数; flowValidation match_type优先级修复 2026-11-03 13:46:27 +08:00
yu d4f27cdaab refactor: 找图族组件统一新管线(Exists/Location/Wait/Move迁至虚拟桌面+相似度语义), 面板只留V1/V2最新版 2026-11-03 13:44:59 +08:00
yu 6027895d4d refactor: 点击图像组件合(V2并入V3): 精简为查找范围/窗口对象/目标图像/按键/位置/相似度七字段 2026-11-03 13:34:42 +08:00
yu 12b97315f2 fix: 窗口对象语义字段(window_obj/窗口对象)统一渲染选窗口按钮, 不再依赖组件前缀 2026-11-03 13:28:49 +08:00
yu 241c598b10 fix: 匹配阈值判定移到精化后并取消初始剪枝, 修复高相似度(0.95)误判未找到 2026-11-03 13:16:45 +08:00
yu 4824ac85f2 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-03 13:15:00 +08:00
yu b5d6bef369 fix: 窗口组件条件字段渲染+选窗口按钮; imageSelector加文件浏览与屏幕截取; prompt()改confirm(); 后端窗口selector统一读取所有标题字段; 新增桌面键鼠人工验收流程与E2E脚本 2026-11-03 13:12:29 +08:00
yu 3922447689 fix: 全桌面查找结果加回拼接画布物理原点, 修复副屏点击垂直偏移 2026-11-03 13:07:12 +08:00
yu e7669acd16 feat: 图像查找搜索整个虚拟桌面(多屏拼接), 窗口矩形与区域JSON统一DPI换算 2026-11-03 11:48:41 +08:00
yu 68e16282de fix: 点击脚本声明DPI感知避免物理坐标被二次缩放; 缩放兜底返回实际框尺寸 2026-11-03 11:39:17 +08:00
yu 9868f5d51c fix: 图像匹配改相似度语义并修三处准确度缺陷(尺度对齐/早退阈值/噪声死区) 2026-11-03 11:31:34 +08:00
yu 38af534f70 fix: 图片识别支持JPG等非PNG模板, 文件缺失与大图给出明确报错 2026-11-03 11:04:04 +08:00
yu 52423d46a4 fix: 图片路径类字段屏幕框选改为存模板截图路径, 仅搜索区域字段存区域JSON 2026-11-03 10:54:35 +08:00
yu 0960be88e5 fix: 框选轮询脚本免 System.Drawing 引用, 采样队列化并取消阻塞等待 2026-11-03 10:35:00 +08:00
yu 82765a7f19 fix: 框选所有退出路径恢复应用窗口; 轮询提前启动并同源采样按键与光标坐标 2026-11-03 10:28:24 +08:00
yu 817ea21c9a fix: 框选改为主进程轮询物理按键状态, 覆盖层最高层级置顶, 全路径清理 2026-11-03 10:15:41 +08:00
yu 257fdc7358 fix: 框选截图顺序与多覆盖层状态聚合\n\n- 主屏截到应用窗口: 之前框选结束后先恢复应用窗口再截图, 应用弹回盖住选区; 改为先销毁覆盖层并截图, 最后恢复应用窗口\n- 副屏拖拽无响应/Esc 无效: 轮询只读第一个(主屏)覆盖层状态; 改为聚合所有覆盖层, 任一屏 Esc 可取消、任一屏拖拽可识别\n- 多显示器截图源匹配增加按下→右排序对位回退(display_id 缺失时) 2026-11-03 09:59:57 +08:00
yu 5e6b7cd878 fix: 框选坐标改由主进程采样光标绝对位置\n\n- 混合 DPI 下窗口 clientX/clientY 与屏幕位置映射不可靠, 导致选区偏离鼠标实际位置\n- 渲染层只上报按下/松开状态, 主进程轮询 getCursorScreenPoint 取屏幕绝对 DIP 坐标计算选区\n- 选区正确性不再依赖覆盖层窗口几何, 裁剪与返回值均基于绝对坐标 2026-11-03 09:46:36 +08:00
yu 0641c5b91b fix: 副屏框选覆盖层尺寸不对\n\n- 混合 DPI 多显示器下, BrowserWindow 构造参数直接传副屏 bounds 会按主屏 DPI 虚拟化, 导致副屏覆盖层盖不满或超出\n- 改为先创建隐藏窗口再 setBounds 副屏 bounds, 此时窗口已关联副屏 DPI 2026-11-03 09:39:58 +08:00
yu b40c15d5b2 feat: 框选与 CV 查找支持多显示器副屏
- pickScreenRegion 每个显示器各开一个框选覆盖层, 副屏可直接框选; 返回屏幕绝对 DIP 坐标+该屏 scaleFactor
- cv-vision 全屏查找改为逐显示器 desktopCapturer 截屏(含负坐标副屏), 命中换算为绝对 DIP 坐标; 区域截图命中按 scaleFactor 换算
- 点击/悬停/输入坐标经 screen.dipToScreenPoint 转物理像素, 修复缩放非 100% 时点击偏移
- 新增 2 个多显示器测试用例(副屏命中映射/区域图缩放换算), 11/11 通过
2026-11-03 09:35:15 +08:00
yu 22054fed37 fix: 截屏改用 desktopCapturer, 修复截 Electron 窗口全黑
- GDI CopyFromScreen 截不到 GPU 合成渲染的窗口内容, 用户框选 Electron 编辑器界面时截图全黑, OCR 无从识别
- pickScreenRegion 改用 desktopCapturer 截屏后按 scaleFactor 裁剪(与 AI 拾取 capturePaneForAi 同款方案)
- cv-vision grabScreen 同步改为 desktopCapturer 优先, 不可用时回退 GDI 脚本
2026-11-03 09:17:23 +08:00
yu d98185d1cf feat: CV 组件搜索区域 UI 接入完成
- catalog 为 CV_MouseClick/GetText/SetText/MouseHover 新增可选「搜索区域」字段(region widget)
- 属性面板 region 字段渲染「屏幕框选」按钮, 框选后存 {x,y,width,height,imagePath}
- 运行时框选截图内 OCR 定位 + 偏移换算屏幕绝对坐标; 纯坐标范围则全屏截屏后过滤区域内命中
- 新增 2 个区域过滤测试用例(区域内命中/区域外拒绝)
2026-11-03 09:10:00 +08:00
yu 5aa113ea0e feat: CV 组件支持可选搜索区域(第一阶段)
- locateTextOnScreen 支持区域参数: 框选截图(imagePath)+屏幕偏移(x,y) 或纯坐标范围过滤
- locateFor 读取 searchRegion/region 参数; parseRegionArg 兼容 JSON 与图片路径
- 框选截图时 OCR 只识别该图, 命中坐标加偏移得到屏幕绝对坐标, 避免同名文本干扰
- pickRegionToField: region 类型字段存完整 {x,y,width,height,imagePath}
- 明日继续: catalog 给 CV 四组件加 searchRegion 字段 + region 分支屏幕框选按钮
2026-11-02 17:55:18 +08:00
yu c93543be1c fix: 移除 CV 组件重复注册与非法单行 here-string
- remaining-core.ts 曾重复注册 CV_GetText/SetText/MouseHover/MouseClick 并覆盖 cv-vision.ts 的实现
- 其中 CV_MouseClick/MouseHover 内联的 Add-Type @'...'@ 单行 here-string 是非法语法(@' 后必须紧跟换行), 必然解析失败报 CLIXML 错误
- 移除重复注册, CV 组件统一走 cv-vision.ts 的 -MemberDefinition 实现
- 顺手修复 Image_Move 系列同款单行 here-string bug(改为 -MemberDefinition)
2026-11-02 17:46:14 +08:00
yu 58c23591a5 feat: 属性面板参数说明优化, 让用户清楚每个参数该填什么
- catalog-overrides 为 vision 组 88 个组件补充 229 个字段的 description/placeholder
  (目标/偏移量/图片路径/引擎选择等高频易混字段), catalog.json 同步应用
- Properties.tsx: 元数据缺描述时按控件类型兜底提示(element/file/folder/region)
  vision 组件 element 字段提示「填写屏幕上可见的目标文本, OCR 定位」
- 必填标识从红星 * 改为「必填」徽标, 更直白
- 字段说明文案样式增强(.field-desc): 10px muted 色置于输入框下方
2026-11-02 17:37:43 +08:00
yu dfc5041a67 fix: CV 截屏声明 DPI 感知并覆盖虚拟桌面, 失败保留截屏证据
- 缩放非 100% 时未声明 DPI 感知的截屏进程只覆盖主屏左上角逻辑区域, 右侧/副屏内容丢失
- grabScreen 先 SetProcessDpiAwarenessContext(-4), 与点击脚本坐标空间一致
- 截屏范围从主屏 PrimaryScreen 改为 VirtualScreen 全部显示器
- cv_not_found 时保留截屏并在错误详情附路径, 便于排查
2026-11-02 17:27:37 +08:00
yu afa32133ec Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-02 17:20:37 +08:00
yu bf5ad56063 fix: 属性面板拾取按钮错位
- vision 组件(CV_/OcrOffline_)的 element 字段不再显示浏览器拾取按钮, 目标为手填屏幕文本并更新占位提示
- 修复 file/folder 分支按钮嵌套错误(框选/选窗口按钮误嵌在浏览按钮内且重复两份), 还原为平级按钮
2026-11-02 17:16:17 +08:00
yu 3bc8b60e83 fix: 画布点阵背景重影
- .flow-canvas .react-flow 的 CSS radial-gradient 点阵与 React Flow Background 组件叠加
- 平移时两层点阵以不同速度移动产生重影; 移除 CSS 层只保留 Background 组件
2026-11-02 17:12:53 +08:00
yu f0b9d1beec fix: CV 视觉组件截屏脚本补加载 System.Windows.Forms
- grabScreen 只 Add-Type 了 System.Drawing, 引用 Windows.Forms.Screen 却未加载该程序集
- 导致 PrimaryScreen.Bounds 取值为 Null, 截屏链路 InvokeMethodOnNull, 所有 CV_* 组件运行失败
- 与 remaining-core.ts 中正常工作的同款脚本对齐
2026-11-02 17:02:42 +08:00
yu f9b980ab5c fix: 搜索框聚焦时不再出现双层边框
- search-box/task-search 内层 input 关闭自身 focus-visible 光圈, 只保留容器聚焦效果
2026-11-02 16:24:35 +08:00
yu 22e3c191a8 fix: 组件库分类默认全部收起
- 新建任务进入编辑器时不再自动展开前两个分组
- 仅在搜索时自动展开匹配分组, 清空搜索恢复收起
2026-11-02 16:21:41 +08:00
yu 32ac14a36f fix: Excel sheet Move fails on zh-CN Excel due to null optional COM args 2026-11-02 16:00:26 +08:00
yu f8859fed77 test: validate capability property schemas and options 2026-11-02 15:55:23 +08:00
yu 541908961b feat: 任务列表去掉状态列, 新增更新时间列
- 移除「已启用/未启用」状态列及全部 status-dot/task-status 样式
- 新增「更新时间」列, 显示任务最近修改时间(History 图标)
- 表格栅格 5 列重排, 清理 1250px 媒体查询对应覆盖
2026-11-02 15:54:50 +08:00
yu 03e3d31a83 feat: 移除右侧任务概览面板,任务列表占满整屏
- 删除 TasksPage 的 aside 任务概览区块及其全部 CSS
- 空状态并入列表: 无任务时在列表内显示「还没有任务+新建按钮」, 搜索无结果显示原提示
- .tasks-content 改为单列布局, 任务列表占满可用宽度
2026-11-02 15:45:35 +08:00
yu bf70cf92e5 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-02 15:45:07 +08:00
yu d7aff54bc6 feat: stabilize AI-picked desktop elements with UIA enrichment and window-relative selectors 2026-11-02 15:44:20 +08:00
yu 56d00fba49 revert: 移除任务列表批量删除功能
- 还原 TasksPage/App/app.css 至 1bb28e3 状态(5795c39 及其未提交的布局调整一并撤销)
- 应用户要求移除复选框/全选/删除所选
2026-11-02 15:33:00 +08:00
yu 5795c39a6f feat: 任务列表支持勾选批量删除
- 每行任务左侧新增复选框; 表头「任务」列新增全选(仅作用于当前筛选结果)
- 勾选后列表头部出现「删除所选(N)」按钮, 二次确认后批量删除
- 批量删除保留「至少保留一个任务」保护; 勾选集合随列表变化自动裁剪已删除 id
- 单行删除行为不变
2026-11-02 15:18:50 +08:00
yu 1bb28e3368 fix: 截图目标文件路径兼容文件夹写法
- 目标文件路径语义为完整文件路径(含文件名), 提示文案不变
- 容错: 填的是已存在文件夹或以分隔符结尾时, 自动往里存默认文件名(screenshot_时间戳.png 或配置的文件名称), 不再报 EISDIR
2026-11-02 15:09:23 +08:00
yu 29f3443110 fix: 保存对话框预填文件名并自动补扩展名; 截图组件隐藏图像来源字段
- Properties.tsx: 输出类字段保存框按过滤器预填默认文件名(未命名.png 等), 不再是空文件名没法直接点保存; 保存结果无扩展名时自动补所选类型扩展名(Windows 保存框不会自动补)
- catalog: Image_Screenshot 的 pictureSource(图像来源) 界面隐藏, 实现默认截当前屏幕; 历史流程存有 2(指定图片文件) 仍按原逻辑生效
2026-11-02 15:01:44 +08:00
yu 88d5e61c5b docs: 报告修复状态补提交号 2026-11-02 14:53:58 +08:00
yu ba17d6dc2a fix: 图像与OCR 检测报告问题全量修复(P0/P1/P2/P3)
- P0-1: GetOcrTextNew 传图键补 uiSelector, 组件恢复可用
- P1-1: Image_Click 全版本 position 生效(九宫格锚点+自定义偏移 JSON), catalog 补下拉选项
- P1-2: Click_V3 click_key 生效(左/右/中键/双击), catalog 补选项
- P1-3: GetOcrTable 按垂直中心聚合成行列(行内按 x 排序), showTable 写流程数据表(与 Bivartable 共享 __rpaDatabook), isExcel 用 exceljs 另存并处理 res/randomString 占位
- P2-1: Find_V1/Click_V3 find_range 生效(整个屏幕/指定窗口/指定区域), 窗口模式前置+取矩形裁剪, 坐标平移回屏幕系
- P2-2(补): Image_Screenshot 空路径兜底到流程证据目录
- P3-1: accuracy2 无特征匹配层实现, catalog 隐藏并注明
- P3-2: show_track=1 时鼠标 20 步插值平滑移动后点击
- P3-3: 截屏/Find 换 desktopCapturer(去掉逐次 Add-Type); 新增 vision-helpers.test.ts 12 项单测(区域解析/边界夹取/裁剪/九宫格)
- 回归: typecheck 通过; vitest 256 过(4 失败为桌面/PDF 环境遗留); 报告文档已标注修复状态
2026-11-02 14:53:34 +08:00
yu 342145cc46 feat: 屏幕截图组件实现图像来源与截图范围(原 P2 问题)
- vision.ts 重写 Image_Screenshot:pictureSource=1 当前屏幕(desktopCapturer)/2 指定图片文件(image_path 作输入, 输出走输出目录+文件名, 未配置则原样返回);shotRange=2 框选区域按 position/action 区域 JSON 裁剪(pngjs bitblt),区域支持 {x,y,width,height}/[x,y,w,h]/left,top/角点写法并夹取边界
- 移除 browser.ts 重复注册,实现单点维护
- catalog: pictureSource/shotRange 补下拉选项(当前屏幕/指定图片文件, 整个屏幕/框选区域)
- typecheck 通过;browser/cv-vision/capability-audit/app-system 回归 50 项全过
2026-11-02 14:36:14 +08:00
yu c11e1d1527 fix: 输出类文件字段改用保存对话框
- 目标文件路径/输出目录这类字段指向将来才生成的文件, 原先的打开文件对话框只能选已存在文件, 导致截屏保存位置无法选择
- 字段键/标签命中 输出|保存|另存|导出|目标文件|output/save/export 等关键词时改用保存对话框, 可直接输入新文件名
- 输入类字段保持打开对话框不变
2026-11-02 14:26:29 +08:00
yu e5f64c21e2 Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-02 14:22:41 +08:00
yu 1ddd1f1b35 feat: enhance AI pane picking with OCR and cancellable hotkeys 2026-11-02 14:21:19 +08:00
yu b1fc8c25b8 fix: 文件选择对话框过滤器按字段上下文智能匹配
- 原先所有文件选择框都写死 Excel 过滤器, 选图片/PDF/Word 等均显示 Excel 类型, 且无法直接看到目标文件
- 现按 字段键/标签关键词 + 组件类别 推导: Excel/Word/PDF/图片/压缩/音频/视频/JSON 各归其类, 均附带「全部文件」兜底
- 无法识别的字段退回「全部文件」; 目录选择不受影响
2026-11-02 14:15:18 +08:00
yu 1c85a90f1f feat: align browser picker with desktop interaction 2026-11-02 14:12:41 +08:00
yu 9b9c6f17e4 fix: launch external pickup directly on target url 2026-11-02 14:05:28 +08:00
yu d39c77324a fix: reuse external browsers for element pickup 2026-11-02 13:44:46 +08:00
yu 927b18551f fix: use runtime registry in app system acceptance 2026-11-02 13:24:48 +08:00
yu 0132871885 feat: deduplicate capability palette 2026-11-02 13:08:03 +08:00
yu d22468e29f feat: remove capability execution status markers 2026-11-02 12:00:39 +08:00
yu e956ec9055 feat: 新增本地UI模型AI拾取与识别实验台 2026-11-02 11:55:36 +08:00
yu ee60435e2b merge: bring liuwenxin app-system fixes into main 2026-11-02 11:07:46 +08:00
yu 510f0d041d merge: integrate liuwenxin app-system fixes 2026-11-02 11:07:27 +08:00
yu 95fb07e876 feat: complete remaining capabilities and hide integration status 2026-11-02 10:43:11 +08:00
yu 66169590de docs: add missing capability inventory 2026-11-02 09:35:04 +08:00
yu a1c65fb42f docs: update capability completion and package status 2026-11-02 09:16:54 +08:00
yu 657bc6d7d0 feat: excel pivot macro picture and workbook COM operations 2026-11-02 09:00:24 +08:00
yu ae4f4a2460 feat: manual keyboard mode and ciphertext variables 2026-11-02 08:23:22 +08:00
yu 9a5f86c92a Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-10-30 18:02:50 +08:00
yu 85dcb94056 feat: 新增桌面AI拾取功能,支持视觉识别控件 2026-10-30 18:02:46 +08:00
yu e390b1b1eb feat: mail find reply and outlook message retrieval 2026-10-30 17:43:07 +08:00
yu 84d73f1b86 feat: pdf encryption image extraction rendering and table reading 2026-10-30 17:26:09 +08:00
yu 23d292fdff feat: word selection cursor and table com 2026-10-30 16:25:44 +08:00
yu 32269a5760 feat: desktop ground variants and desktop children 2026-10-30 16:02:21 +08:00
yu adf5f3f7cf feat: CV 视觉四能力(截图OCR定位点击/悬停/读文本/输入),修复 path import 与 getOcr 类型 2026-10-30 15:15:57 +08:00
94 changed files with 43895 additions and 1999 deletions

No files matched your search

+2
View File
@@ -12,3 +12,5 @@ test-output/
web-capability-audit.txt web-capability-audit.txt
*.traineddata *.traineddata
temp/
+9 -6
View File
@@ -1,16 +1,16 @@
# RPA Studio # RPA Studio
画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。 画布式 RPA 桌面端(Electron + React Flow)。把 658 项主能力拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。
> 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。 > 当前状态:任务优先的编排工作台。默认进入“编排”任务列表,编辑任务后进入上下流向画布;组件库按分类树展示,设置为独立入口。
> 可执行能力持续接入中:**732 / 1016**(网页自动化、桌面自动化、数据、文件、办公、HTTP 等),其余灰标「待接入」,运行前检查会提前定位。 > 当前可编排能力:**658 / 894**(122 项不接入和 236 项重复旧版本已分离;网页、桌面、数据、文件、办公、HTTP 等主能力可直接编排)。
## 已实现 ## 已实现
### 编排与运行 ### 编排与运行
- Electron 31 + React 18 + TypeScript + React Flow 画布 - Electron 31 + React 18 + TypeScript + React Flow 画布
- 1016 项组件目录:搜索 / 11 组筛选 / 可执行与待接入状态;任务列表实时刷新 - 658 项主能力目录:搜索 / 11 组筛选;236 项重复旧版本自动隐藏,122 项专用能力已分离备份;任务列表实时刷新
- 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做 - 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做
- IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体 - IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体
- 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时) - 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时)
@@ -42,7 +42,8 @@
### 质量与测试 ### 质量与测试
- 全量测试 206 项(能力注册冒烟、流程运行集成、组件缺陷回归、测试流程包与安装保护、Word/Excel/PDF/桌面桥真实设备测试) - 全量测试 231 项(能力注册冒烟、流程运行集成、组件缺陷回归、测试流程包与安装保护、Word/Excel/PDF/桌面桥真实设备测试)
- 应用与系统 50 项组件验收:13 组任务、75 个用例,含真实执行与人工确认项
- 本地演示站(`127.0.0.1:17863`):网页订单页 + 网页元素演练场(表单/下拉/勾选/密码/禁用按钮/悬停/长页面) - 本地演示站(`127.0.0.1:17863`):网页订单页 + 网页元素演练场(表单/下拉/勾选/密码/禁用按钮/悬停/长页面)
- 内置 3 个网页自动化测试任务(浏览器导航、元素读写、滚动与 Cookie)+ 6 条固定示例任务 - 内置 3 个网页自动化测试任务(浏览器导航、元素读写、滚动与 Cookie)+ 6 条固定示例任务
- 打包冒烟测试:跳过单实例锁隔离启动,验证产物可用性 - 打包冒烟测试:跳过单实例锁隔离启动,验证产物可用性
@@ -79,13 +80,15 @@ docs 现状分析、实施方案
## 文档 ## 文档
- [应用与系统 49 项组件测试任务](resources/test-flows/app-system/README.md):13 组可安装任务、使用步骤、覆盖清单及实际验收报告。 - [应用与系统 50 项组件测试任务](resources/test-flows/app-system/README.md):13 组可安装任务、使用步骤、覆盖清单及实际验收报告。
- [现状分析与可行性](docs/01-现状分析与可行性.md) - [现状分析与可行性](docs/01-现状分析与可行性.md)
- [实施方案(三天)](docs/02-实施方案.md) - [实施方案(三天)](docs/02-实施方案.md)
- [未接入能力名单](docs/04-未接入名单.md)
- [重复能力去留清单](docs/05-重复能力去留清单.md)
## 边界 ## 边界
- 1016 项目录中当前 732 项可执行;未接入的主要为:异常监控组件、桌面 Ground 剩余变体、SAP 专用、邮件收件(IMAP)、Word 选区 COM 等。图表数据读取(ECharts)已接入;下载对话框为明确不接入(下载能力已覆盖)。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。 - 原始目录 1016 项中,894 项未被排除;其中 658 项主能力进入组件库,236 项重复旧版本在 `resources/capability-duplicates.json` 中标记兼容隐藏,122 项专用/厂商硬件/门户通道在 `resources/excluded.json` 中备份。组件库不显示接入状态。
- 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。 - 内置本地演示站默认监听 `127.0.0.1:17863`;新用户首次启动会获得“本地网页订单演示”任务,可离线验证网页输入、点击和文本读取。
- 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。 - 编排首页提供“安装示例任务”,可安装网页订单、文件文本、Excel读写、剪贴板、Word文档和异常分支 6 条固定示例;重复安装会更新同名示例,不会无限复制。
- Tauri/WebView2 类应用的 UIA 支持天然受限(Invoke 假成功、ValuePattern 读回掩码):桌面元素点击采用“前台化 + 真实坐标点击”策略,输入采用“ValuePattern 写入 + 剪贴板粘贴兜底”。 - Tauri/WebView2 类应用的 UIA 支持天然受限(Invoke 假成功、ValuePattern 读回掩码):桌面元素点击采用“前台化 + 真实坐标点击”策略,输入采用“ValuePattern 写入 + 剪贴板粘贴兜底”。
+19 -84
View File
@@ -1,6 +1,6 @@
# 能力接入规划与排期 # 能力接入规划与排期
> 生成时间:2026-10-28 · 校准时间:2026-10-30 · 目录 1016 项 · 已接入 732 项(运行时注册 744 个 id;以 src/main/catalog.ts 判定为准) > 生成时间:2026-10-28 · 校准时间:2026-11-02 · 收口时间:2026-11-02 · 原始目录 1016 项 · 可编排目录 894 项(运行时注册 906 个 id;122 项不接入已分离备份)
## 一、明确不接入清单(永久排除) ## 一、明确不接入清单(永久排除)
@@ -182,22 +182,30 @@
## 二、暂缓池 ## 二、暂缓池
### 已完成(超出原排期,2026-10-30 校准) ### 已完成(超出原排期,2026-11-02 校准)
- [P1] 人机交互对话框 17/17:输入/文件/消息/自定义表单对话框(`src/main/capabilities/human-dialog.ts`) - [P1] 人机交互对话框 17/17:输入/文件/消息/自定义表单对话框(`src/main/capabilities/human-dialog.ts`)
- [P2] Windows 系统 12/12:凭据/远程桌面/屏保/输入法(`src/main/capabilities/windows-system.ts`) - [P2] Windows 系统 12/12:凭据/远程桌面/屏保/输入法(`src/main/capabilities/windows-system.ts`)
- [P2] 图表数据读取 2/2:Element_GetLineBarChartData、Element_GetPieChartData(`src/main/capabilities/web-batch2.ts`) - [P2] 图表数据读取 2/2:Element_GetLineBarChartData、Element_GetPieChartData(`src/main/capabilities/web-batch2.ts`)
- [P2] Excel 高阶 14/38:单元格格式、区域选择、行列显隐、尾部空白单元格、列标识、表格样式、Load/Close(`src/main/capabilities/excel-common.ts`) - [P2] Excel 高阶 38/38:单元格格式、区域选择、行列显隐、尾部空白单元格、列标识、表格样式、Load/Close(`src/main/capabilities/excel-common.ts`);组合、图片、加密、移动、合并、透视表、宏、PDF 导出、Interop、切片器、循环(`src/main/capabilities/excel-com.ts`)
- [P3] 待评估池移出 20 项:FTP 10 项(`src/main/capabilities/ftp.ts`)、NLP 9 项(AI 服务 `src/main/capabilities/ai-caps.ts`)、File_OpenFile - [P3] 待评估池移出 20 项:FTP 10 项(`src/main/capabilities/ftp.ts`)、NLP 9 项(AI 服务 `src/main/capabilities/ai-caps.ts`)、File_OpenFile
- 原排除清单移出 12 项:见第一节「已接入」 - 原排除清单移出 12 项:见第一节「已接入」
以下为剩余暂缓项: 以下为剩余暂缓项:
### [P3] 待评估(84 个) ### 2026-11-02 完成批次(64 个)
- 桌面 Ground 补全 13/13:12 个 `*Ground` 变体 + `Element_DesktopChildren`(`src/main/capabilities/desktop-uia.ts`)
- Word 选区/光标/表格 COM 11/11(`src/main/capabilities/word-extended.ts`)
- PDF 加密/提取图片/转图片/表格 4/4(`src/main/capabilities/pdf.ts`)
- 邮件查找/回复/Outlook 读取 9/9(`src/main/capabilities/message.ts`)
- 键盘模拟人工开关 2/2 与 `Basic_Dodecryption` 1/1(`src/main/capabilities/desktop.ts`、`basic-encryption.ts`)
- Excel COM 专属 24/24(`src/main/capabilities/excel-com.ts`)
### [P3] 待评估(80 个)
- child_modules_v1 | 调用子流程 - child_modules_v1 | 调用子流程
- concurrent_child_process_v1 | 并发调用子流程 - concurrent_child_process_v1 | 并发调用子流程
- Basic_Dodecryption | 设置密文变量
- Database_BatchSQLExecute | 批量执行SQL语句 - Database_BatchSQLExecute | 批量执行SQL语句
- Database_DBConnect | 连接数据库 - Database_DBConnect | 连接数据库
- Database_SingleSQLQuery | 执行单SQL查询 - Database_SingleSQLQuery | 执行单SQL查询
@@ -206,7 +214,6 @@
- Database_CloseDBConnect | 关闭连接 - Database_CloseDBConnect | 关闭连接
- Database_SQLExecute | 执行SQL语句 - Database_SQLExecute | 执行SQL语句
- Database_AllSQLQuery | 执行全SQL查询 - Database_AllSQLQuery | 执行全SQL查询
- Element_DesktopChildren | 获取子元素
- File_UploadBusinessResults_v1 | 上报办理结果 - File_UploadBusinessResults_v1 | 上报办理结果
- File_StartBusiness | 上报办理业务 - File_StartBusiness | 上报办理业务
- File_UploadBusinessResults | 上报办理结果(门户专用) - File_UploadBusinessResults | 上报办理结果(门户专用)
@@ -240,8 +247,6 @@
- Image_Wait | 等待图像 - Image_Wait | 等待图像
- Image_Move | 移动到图像 - Image_Move | 移动到图像
- Image_Move_V1 | 移动到图像 - Image_Move_V1 | 移动到图像
- Keyboard_EnableManualOperation | 开启模拟人工操作
- Keyboard_DisableManualOperation | 关闭模拟人工操作
- Mark_Mark | 图片打标 - Mark_Mark | 图片打标
- NLP_XunFei_MachineTranslation | 机器翻译 - NLP_XunFei_MachineTranslation | 机器翻译
- NLP_XunFei_FeelingAnalysis | 第三方情感分析模型 - NLP_XunFei_FeelingAnalysis | 第三方情感分析模型
@@ -288,49 +293,7 @@
- CV_MouseHover | 移动到元素上 - CV_MouseHover | 移动到元素上
- CV_MouseClick | 元素点击 - CV_MouseClick | 元素点击
### [P3] 桌面 Ground 剩余变体(12 个) ### [P2] 邮件收件:剩余连接/列表/操作/Exchange(19 个;查找、回复、Outlook 读取 9 个已完成)
- Element_GetCheckGround | 获取勾选框状态值
- Element_SetCheckGround | 设置勾选框状态
- Element_RegionGround | 获取元素坐标
- Element_GetSelectGround | 获取下拉框选项值
- Element_MouseToGround | 鼠标移动到元素上
- Element_SetAttributeGround | 设置网页元素属性
- Element_ChildrenGround | 获取子元素
- Element_GetAttributeGround | 获取网页元素属性
- Element_ScrollIntoViewGround | 滚动页面
- Element_SetSelectGround | 设置下拉框选择项
- Element_ShotGround | 元素截图
- Element_GetButtonEnableGround | 元素是否可点击
### [P2] Excel 高阶:格式/图片/加密/透视表/筛选等(24 个)
- Excel_Cs_Group | 组合行列
- Excel_Cs_DeletePicture | 删除图片
- Excel_Cs_RefreshPivotTable | 刷新Excel数据透视表
- Excel_Cs_DeletePictureV2 | 删除图片
- Excel_Cs_Encrypt | 加密Excel
- Excel_Cs_Move | 移动Sheet
- Excel_Cs_InsertPicture | 插入图片
- Excel_Cs_Merge | 合并Sheet
- Excel_InsertImage | 插入图片
- Excel_Cs_InsertPivotView | 新建透视图
- Excel_Cs_Interop | Excel动态调用
- Excel_Cs_InsertPivotTable | 新建Excel数据透视表
- Excel_Cs_RunMacro | 执行宏
- Excel_Loop | excel内容循环
- Excel_Cs_Export | 导出Excel为PDF
- Excel_Cs_ExcelAddin | 数据处理
- Excel_Cs_InsertPictureV2 | 插入图片
- Excel_Cs_FilterPivotTable | 筛选Excel数据透视表
- Excel_Cs_RunMacroV2 | 执行宏
- Excel_Cs_SetSlicer | Excel切片器
- Excel_ExecuteMacro | 执行宏
- Excel_Cs_RefreshAll | 刷新Excel数据
- Excel_DeleteImage | 删除图片
- Excel_ExportRangeAsPicture | 导出区域为图片
### [P2] 邮件收件:需 IMAP 库(28 个)
- Mail_UnmarkSeen | 标为未读 - Mail_UnmarkSeen | 标为未读
- Mail_DoReplyMail_v1 | 回复邮件(Outlook) - Mail_DoReplyMail_v1 | 回复邮件(Outlook)
@@ -338,49 +301,19 @@
- Mail_RecvMailGet | 取第几封邮件 - Mail_RecvMailGet | 取第几封邮件
- Mail_ExOperateMail | 操作邮件 - Mail_ExOperateMail | 操作邮件
- Mail_RecvGetList | 邮件筛选 - Mail_RecvGetList | 邮件筛选
- Mail_ReplyMail_v1 | 回复邮件
- Mail_FindMail_v1 | 查找邮件
- Mail_DoMoveMail | 移动邮件 - Mail_DoMoveMail | 移动邮件
- Mail_UnionConnect | 连接邮箱 - Mail_UnionConnect | 连接邮箱
- Mail_ExConnect | 连接Exchange邮箱 - Mail_ExConnect | 连接Exchange邮箱
- Mail_RecvClose | 关闭连接 - Mail_RecvClose | 关闭连接
- Mail_DoSendMail | 发送邮件 - Mail_DoSendMail | 发送邮件
- Mail_RecvCount | 取收件箱邮件数 - Mail_RecvCount | 取收件箱邮件数
- Mail_DoGetMailMessage_v2 | 获取邮件(Outlook)
- Mail_DoGetMailMessage_v1 | 查找邮件(Outlook)
- Mail_ReplyMail | 回复邮件
- Mail_FindMail_v2 | 获取邮件
- Mail_ExSearchMail | 查找邮件 - Mail_ExSearchMail | 查找邮件
- Mail_DoGetMailMessage | 查看邮件
- Mail_ExSendMail | 发送邮件 - Mail_ExSendMail | 发送邮件
- Mail_OperateMail | 操作邮件 - Mail_OperateMail | 操作邮件
- Mail_MarkSeen | 标为已读 - Mail_MarkSeen | 标为已读
- Mail_DoMoveMail_v1 | 操作邮件(Outlook) - Mail_DoMoveMail_v1 | 操作邮件(Outlook)
- Mail_ReplyMail_v2 | 回复邮件
- Mail_DoReplyMail | 回复邮件 - Mail_DoReplyMail | 回复邮件
- Mail_DoSendMail_v1 | 发送邮件(Outlook) - Mail_DoSendMail_v1 | 发送邮件(Outlook)
- Mail_FindMail | 查找邮件
### [P2] PDF 高阶:需引入渲染能力(4 个)
- PDF_PdfEncryption | 加密 PDF 文档
- PDF_GetPdfImg | 提取 PDF 图片
- PDF_GetPdfTable | 读取 PDF 表格
- PDF_PdfToImg | PDF 转图片
### [P2] Word 选区与光标:COM 操作(11 个)
- Word_ReadSelect | 读取选中文本
- Word_CutSelect | 剪切
- Word_ReadTable | 读取Word表格
- Word_SelectRow | 选择行
- Word_DeleteSelect | 删除
- Word_SetPosition | 设置光标位置
- Word_PositionToSelect | 查找文本后设置光标位置
- Word_MovePosition | 移动光标位置
- Word_CopySelect | 复制
- Word_PasteSelect | 粘贴
- Word_SelectAll | 全选
## 三、接入排期总览 ## 三、接入排期总览
@@ -389,14 +322,16 @@
| P0 | 网页核心 170、桌面 Ground 核心 5、网页批二 15 id、网页批三 16 id | 已完成 2026-10-28 | | P0 | 网页核心 170、桌面 Ground 核心 5、网页批二 15 id、网页批三 16 id | 已完成 2026-10-28 |
| P1 | 人机交互对话框 17、Time 家族 24 | 已完成 2026-10-30 校准 | | P1 | 人机交互对话框 17、Time 家族 24 | 已完成 2026-10-30 校准 |
| P1 | 文件上传验证 | 下一迭代 | | P1 | 文件上传验证 | 下一迭代 |
| P2 | Excel 高阶 24、PDF 高阶 4、Word 选区 11、邮件收件 28、视觉 5 | 后续迭代(图表 2、Windows 系统 12 已完成) | | P2 | 最后一波 98:子流程、数据库、转换、Google Sheets、图像、CV、NLP、OCR、邮件剩余 | 已完成 2026-11-02 |
| P3 | 桌面 Ground 剩余 12、待评估 84 | 按需 | | 备份 | 门户上报、图片打标、移动硬件、厂商云服务、SAP 等 122 项 | 已分离 `resources/excluded.json` |
| 永久排除 | 明确不接入清单 116 项(第一节) | 不排期 | | 永久排除 | 明确不接入清单 116 项(第一节) | 不排期 |
## 四、落地机制 ## 四、落地机制
- 本文档为能力接入的唯一规划来源;新增能力须先在此登记阶段 - 本文档为能力接入的唯一规划来源;新增能力须先在此登记阶段
- 2026-10-30 校准:数字以运行时注册表为准(744 个 id,目录可执行 732 / 1016);原排除/暂缓中已实现的 78 项已移入「已接入 / 已完成」 - 2026-11-02 校准:数字以运行时注册表为准(808 个 id,目录可执行 796 / 1016);2026-10-30 校准后原排除/暂缓中已实现的 78 项已移入「已接入 / 已完成」
- 本批次新增依赖:`@cantoo/pdf-lib`(PDF 加密)、`@napi-rs/canvas`(PDF 渲染)、`imapflow`/`mailparser`(IMAP 收件)、`sm-crypto`(SM4 密文变量)
- 不接入能力备份:[04-未接入名单](04-未接入名单.md)
- 下一步:将第一节清单固化为 resources/excluded.json,组件库显示「不接入」灰标 - 下一步:将第一节清单固化为 resources/excluded.json,组件库显示「不接入」灰标
## 六、桌面拾取与定位——原厂实现解读与我们的增强项(2026-10-30) ## 六、桌面拾取与定位——原厂实现解读与我们的增强项(2026-10-30)
+141
View File
@@ -0,0 +1,141 @@
# 不接入能力备份
> 快照时间:2026-11-02 · 该清单已从可编排目录分离,仅作能力溯源备份。
## 汇总
| 状态 | 数量 |
|---|---:|
| 原始目录总数 | 1016 |
| 当前可编排目录 | 894 |
| 不接入备份 | 122 |
| 运行时注册 id | 906 |
| ID | 名称 | 模块 | 原厂函数 |
|---|---|---|---|
| `Basic_GetSystemAccount_v2` | 获取平台账号(通用) | app | `Basic.GetSystemAccount_v2` |
| `Basic_GetSystemAccount` | 获取系统账号(门户专用) | app | `Basic.GetSystemAccount` |
| `Basic_GetSystemAccount_v1` | 获取平台账号 | app | `Basic.GetSystemAccount_v1` |
| `DataService_DeleteControllerFiles_v1` | 删除文件 | message | `DataService.DeleteControllerFiles_v1` |
| `DataService_ReportException` | 上报异常信息 | message | `DataService.ReportException` |
| `DataService_ProductionQueue` | 将数据存入队列 | message | `DataService.ProductionQueue` |
| `DataService_UploadControllerFiles_v1` | 上传文件 | message | `DataService.UploadControllerFiles_v1` |
| `DataService_ServiceNew` | 数据服务(松塔) | message | `DataService.ServiceNew` |
| `DataService_DeleteControllerFiles` | 删除控制器文件 | message | `DataService.DeleteControllerFiles` |
| `DataService_Service` | 数据服务 | message | `DataService.Service` |
| `DataService_DownloadControllerFiles` | 下载控制器文件 | message | `DataService.DownloadControllerFiles` |
| `DataService_DownloadControllerFiles_v1` | 下载文件 | message | `DataService.DownloadControllerFiles_v1` |
| `DataService_ConsumptionQueue` | 从队列获取数据 | message | `DataService.ConsumptionQueue` |
| `DataService_UploadControllerFiles` | 上传文件至控制器 | message | `DataService.UploadControllerFiles` |
| `DataService_AiScreenText` | 提取屏幕数据(AI) | message | `DataService.AiScreenText` |
| `DataService_AiScreenDate` | 选择日期时间(AI) | message | `DataService.AiScreenDate` |
| `DataService_AiScreenOperate` | 操作屏幕(AI) | message | `DataService.AiScreenOperate` |
| `Element_DoubleClickSap` | 双击SAP表格单元格 | web | `Element.DoubleClickSap` |
| `File_UploadBusinessResults_v1` | 上报办理结果 | file | `File.UploadBusinessResults_v1` |
| `File_StartBusiness` | 上报办理业务 | file | `File.StartBusiness` |
| `File_UploadBusinessResults` | 上报办理结果(门户专用) | file | `File.UploadBusinessResults` |
| `File_Collectupload` | 上报采集数据 | file | `File.Collectupload` |
| `File_UploadFolder` | 结果打包上传 | file | `File.UploadFolder` |
| `Mark_Mark` | 图片打标 | vision | `Mark.Mark` |
| `Mobile_ConnectPhone` | 连接手机 | mobile | `Mobile.ConnectPhone` |
| `Mobile_MobileElementAarray` | 获取手机相似元素数组 | mobile | `Mobile.MobileElementAarray` |
| `Mobile_Call` | 拨打电话 | mobile | `Mobile.Call` |
| `Mobile_MobileSetText_V1` | 手机键盘输入 | mobile | `Mobile.MobileSetText_V1` |
| `Mobile_ClickButton` | 系统导航键 | mobile | `Mobile.ClickButton` |
| `Mobile_SetInputV1` | 手机输入文本 | mobile | `Mobile.SetInputV1` |
| `Mobile_ConnectPhoneV3` | 连接手机 | mobile | `Mobile.ConnectPhoneV3` |
| `Mobile_GetAttribute` | 手机元素属性拾取 | mobile | `Mobile.GetAttribute` |
| `Mobile_Exists` | 手机元素判断 | mobile | `Mobile.Exists` |
| `Mobile_MobileElementList` | 获取手机相似元素列表 | mobile | `Mobile.MobileElementList` |
| `Mobile_FileTransfer` | 手机文件传输 | mobile | `Mobile.FileTransfer` |
| `Mobile_GetPhoneInfomation` | 获取手机连接详情 | mobile | `Mobile.GetPhoneInfomation` |
| `Mobile_ClickLocation` | 手机屏幕坐标点击 | mobile | `Mobile.ClickLocation` |
| `Mobile_SaveFilePhone` | 保存文件到手机 | mobile | `Mobile.SaveFilePhone` |
| `Mobile_SetInput` | 设置文本|填写输入框 | mobile | `Mobile.SetInput` |
| `Mobile_MobileCvClick` | 手机智能元素点击 | mobile | `Mobile.MobileCvClick` |
| `Mobile_TouchSlideV1` | 触摸滑动 | mobile | `Mobile.TouchSlideV1` |
| `Mobile_GetPhoneFiles` | 从手机中获取文件 | mobile | `Mobile.GetPhoneFiles` |
| `Mobile_OpenMobileApp` | 打开手机APP | mobile | `Mobile.OpenMobileApp` |
| `Mobile_TouchSlide` | 触摸滑动 | mobile | `Mobile.TouchSlide` |
| `Mobile_GetMobileUiTree` | 获取手机UI树 | mobile | `Mobile.GetMobileUiTree` |
| `Mobile_GetScreenOrientation` | 获取手机屏幕方向 | mobile | `Mobile.GetScreenOrientation` |
| `Mobile_GetElement` | 获取手机元素对象 | mobile | `Mobile.GetElement` |
| `Mobile_MobileCvSetText` | 智能设置文本|填写输入框 | mobile | `Mobile.MobileCvSetText` |
| `Mobile_ClosePhone` | 关闭连接手机 | mobile | `Mobile.ClosePhone` |
| `Mobile_SetInputV2` | 手机输入文本 | mobile | `Mobile.SetInputV2` |
| `Mobile_SetScreenOrientation` | 设置手机屏幕方向 | mobile | `Mobile.SetScreenOrientation` |
| `Mobile_ImageClick` | 点击手机图像 | mobile | `Mobile.ImageClick` |
| `Mobile_CloseMobileApp` | 关闭手机APP | mobile | `Mobile.CloseMobileApp` |
| `Mobile_ElementShot` | 手机元素截图 | mobile | `Mobile.ElementShot` |
| `Mobile_ScreenShot` | 手机截屏 | mobile | `Mobile.ScreenShot` |
| `Mobile_GetClipboardText` | 获取手机剪切板文本 | mobile | `Mobile.GetClipboardText` |
| `Mobile_ConnectPhoneV2` | 连接手机 | mobile | `Mobile.ConnectPhoneV2` |
| `Mobile_GetText` | 手机元素文本拾取 | mobile | `Mobile.GetText` |
| `Mobile_MobileSetText` | 手机键盘输入 | mobile | `Mobile.MobileSetText` |
| `Mobile_MobileElementList_V1` | 获取手机相似元素列表 | mobile | `Mobile.MobileElementList_V1` |
| `Mobile_ElementClick` | 手机元素点击 | mobile | `Mobile.ElementClick` |
| `Mobile_GetChildren` | 获取手机子元素 | mobile | `Mobile.GetChildren` |
| `Mobile_SetClipboardText` | 设置手机剪切板文本 | mobile | `Mobile.SetClipboardText` |
| `Mobile_ConnectPhoneV1` | 连接手机 | mobile | `Mobile.ConnectPhoneV1` |
| `OCR_RotateVerificationCodeV1` | 旋转图片验证码 | vision | `OCR.RotateVerificationCodeV1` |
| `OCR_PuzzleVerificationCode_v1` | 推理拼图验证码 | vision | `OCR.PuzzleVerificationCode_v1` |
| `OCR_ClickVerificationCodeV2` | 点选验证码 | vision | `OCR.ClickVerificationCodeV2` |
| `OCR_SourceCommonEnglishNumbersCodeV2` | 普通验证码 | vision | `OCR.SourceCommonEnglishNumbersCodeV2` |
| `OCR_SimpleSliderVerificationCode` | 滑块验证码(滑动条式) | vision | `OCR.SimpleSliderVerificationCode` |
| `OCR_SourceMergeCardOCR` | 识别卡证文字 | vision | `OCR.SourceMergeCardOCR` |
| `OCR_SliderVerificationCodeV5` | 滑动拼图验证码 | vision | `OCR.SliderVerificationCodeV5` |
| `OCR_SliderVerificationCodeV4` | 滑动拼图验证码 | vision | `OCR.SliderVerificationCodeV4` |
| `OCR_SourceMergeOtherOCR` | 识别其他场景文字 | vision | `OCR.SourceMergeOtherOCR` |
| `OCR_SliderCode` | 滑块验证码 | vision | `OCR.SliderCode` |
| `OCR_SourceMergeGeneralOCR` | 通用文字识别 | vision | `OCR.SourceMergeGeneralOCR` |
| `OCR_RotateVerificationCodeV2` | 旋转图片验证码 | vision | `OCR.RotateVerificationCodeV2` |
| `OCR_RotateVerificationCode` | 滑块验证码(旋转式) | vision | `OCR.RotateVerificationCode` |
| `OCR_ClickVerificationCodeV1` | 点选验证码 | vision | `OCR.ClickVerificationCodeV1` |
| `OCR_PuzzleVerificationCode` | 推理拼图验证码 | vision | `OCR.PuzzleVerificationCode` |
| `OCR_SimpleSliderVerificationCodeV2` | 滑动条验证码 | vision | `OCR.SimpleSliderVerificationCodeV2` |
| `OCR_SourceCommonEnglishNumbersCode` | 普通验证码 | vision | `OCR.SourceCommonEnglishNumbersCode` |
| `OCR_SourceMergeCarOCR` | 识别汽车场景文字 | vision | `OCR.SourceMergeCarOCR` |
| `OCR_SliderVerificationCodeV2` | 滑块验证码(拼图样式) | vision | `OCR.SliderVerificationCodeV2` |
| `OCR_SliderVerificationCodeV1` | 滑块验证码(拼图样式) | vision | `OCR.SliderVerificationCodeV1` |
| `OCR_SliderVerificationCode` | 滑块验证码(拼图样式) | vision | `OCR.SliderVerificationCode` |
| `OCR_SourceMergeEducationOCR` | 识别教育场景文字 | vision | `OCR.SourceMergeEducationOCR` |
| `OCR_SliderVerificationCodeV3` | 滑动拼图验证码 | vision | `OCR.SliderVerificationCodeV3` |
| `OCR_ClickVerificationCode` | 点选验证码 | vision | `OCR.ClickVerificationCode` |
| `OCR_SourceCommonEnglishNumbersCodeV1` | 普通验证码 | vision | `OCR.SourceCommonEnglishNumbersCodeV1` |
| `OCR_PuzzleVerificationCode_v2` | 推理拼图验证码 | vision | `OCR.PuzzleVerificationCode_v2` |
| `OCR_SourceMergePaperOCR` | 识别票据文字 | vision | `OCR.SourceMergePaperOCR` |
| `OCR_SimpleSliderVerificationCodeV1` | 滑动条验证码 | vision | `OCR.SimpleSliderVerificationCodeV1` |
| `OCR_SourceMergeGeneralOCR_V1` | 识别通用文字 | vision | `OCR.SourceMergeGeneralOCR_V1` |
| `OCR_SliderVerificationCodePosition` | 获取拼图滑块位置 | vision | `OCR.SliderVerificationCodePosition` |
| `OcrOffline_ClickOcrText_V1` | 鼠标点击OCR文本 | vision | `OcrOffline.ClickOcrText_V1` |
| `OcrOffline_MoveToOcrText` | 鼠标移动到OCR文本上 | vision | `OcrOffline.MoveToOcrText` |
| `OcrOffline_ClickOcrText` | 鼠标点击OCR文本 | vision | `OcrOffline.ClickOcrText` |
| `OcrOffline_MoveToOcrText_V1` | 鼠标移动到OCR文本上 | vision | `OcrOffline.MoveToOcrText_V1` |
| `OCR_BaiDu_CarOCR` | 汽车场景类OCR | vision | `OCR_BaiDu.CarOCR` |
| `OCR_BaiDu_OtherOCR` | 其他场景类OCR | vision | `OCR_BaiDu.OtherOCR` |
| `OCR_BaiDu_EducationOCR` | 教育场景类OCR | vision | `OCR_BaiDu.EducationOCR` |
| `OCR_BaiDu_GeneralOCR` | 通用OCR | vision | `OCR_BaiDu.GeneralOCR` |
| `OCR_BaiDu_CardOCR` | 卡片类OCR | vision | `OCR_BaiDu.CardOCR` |
| `OCR_BaiDu_PaperOCR` | 票据类OCR | vision | `OCR_BaiDu.PaperOCR` |
| `peripherals_UsbCom` | 开/关USB通道 | mobile | `peripherals.UsbCom` |
| `peripherals_CloseNetworkUsbCom` | 关闭NetworkUSB通道 | mobile | `peripherals.CloseNetworkUsbCom` |
| `peripherals_CloseUsbCom` | 关闭USB通道 | mobile | `peripherals.CloseUsbCom` |
| `peripherals_NetworkUsbCom` | 开/关NetworkUSB通道 | mobile | `peripherals.NetworkUsbCom` |
| `peripherals_OpenNetworkUsbCom` | 开启NetworkUSB通道 | mobile | `peripherals.OpenNetworkUsbCom` |
| `peripherals_OpenUsbCom` | 开启USB通道 | mobile | `peripherals.OpenUsbCom` |
| `SafeControlInput_KeyInput` | 安全控件文本输入 | desktop | `SafeControlInput.KeyInput` |
| `Scenario_CreditVerify` | 征信报告解析 | message | `Scenario.CreditVerify` |
| `Scenario_VatInvoice` | 增值税发票验真 | message | `Scenario.VatInvoice` |
| `Scenario_PhysicalExamination` | 体检报告解析 | message | `Scenario.PhysicalExamination` |
| `System_ResetMechanicalArm` | 复位机械臂 | app | `System.ResetMechanicalArm` |
| `System_MechanicalArmClick` | 机械臂点击 | app | `System.MechanicalArmClick` |
| `System_ConnectingUshield` | 连接U盾 | app | `System.ConnectingUshield` |
| `System_ConnectingMechanicalArm` | 连接机械臂 | app | `System.ConnectingMechanicalArm` |
| `System_CloseMechanicalArm` | 断开机械臂 | app | `System.CloseMechanicalArm` |
| `System_CloseUshield` | 断开U盾 | app | `System.CloseUshield` |
| `System_GetMobileMessage` | 获取手机短信 | app | `System.GetMobileMessage` |
| `WebBrowser_DownloadDialog` | 处理下载对话框 | web | `WebBrowser.DownloadDialog` |
## 恢复方式
如后续需要恢复某项,先实现对应 handler,再从 `resources/excluded.json` 的 `ids` 与 `components` 中移除,并重新运行 `npm run package`。
+925
View File
@@ -0,0 +1,925 @@
# 重复能力去留清单
> 快照时间:2026-11-02 · 保留 658 个主能力,隐藏 236 个重复旧版本 / 别名;执行器保留,旧流程不受影响。
## 去留规则
1. 先按能力名称聚合。
2. 再按目标域拆分:CV、桌面 Ground、网页元素、Excel、数据库、邮件协议等不互相吞并。
3. 同域内保留最新主版本;带 `Vn` 的优先级高于基础旧 ID,`New` 仅在没有更高版本时保留。
4. 重复 ID 标记 `duplicateOf` 后从组件库隐藏,但运行器和旧流程兼容保留。
## 汇总
| 状态 | 数量 |
|---|---:|
| 保留主能力 | 658 |
| 隐藏重复能力 | 236 |
| 原可编排数量 | 894 |
| 清理后组件库数量 | 658 |
## 保留主能力
| ID | 名称 |
|---|---|
| `Trigger.Manual` | 人工发起 |
| `Trigger.Schedule` | 定时触发 |
| `Trigger.FileArrival` | 文件到达触发 |
| `logic_control_if_else` | IF 条件分支 |
| `logic_control_var` | 设置变量 |
| `Basic_SetVariable_V1` | 设置变量 |
| `logic_control_count_for` | 计次循环 |
| `logic_control_while` | 条件循环 |
| `infinite_loop` | 无限循环 |
| `logic_control_next_for` | 继续下次循环 |
| `logic_control_break_for` | 跳出循环 |
| `logic_control_try_catch` | 异常捕获 |
| `logic_control_stop` | 退出流程 |
| `logic_control_stop_return` | 返回并退出流程 |
| `child_modules_v1` | 调用子流程 |
| `concurrent_child_process_v1` | 并发调用子流程 |
| `logic_control_array_for_v3` | 遍历列表 |
| `logic_control_dictionary_for_v1` | 遍历字典 |
| `Element_exception_monitor_v2` | 异常监控 |
| `logic_control_if_ai_vision` | 判断屏幕图像(AI) |
| `App_Run` | 启动应用程序 |
| `App_GetPid` | 获取一个匹配PID |
| `App_GetPids` | 获取全部匹配PID |
| `App_KillV1` | 关闭应用程序 |
| `Array_Reshape2Dim` | 转化数组维度 |
| `Basic_IsDictionary` | 判断数据是否为字典 |
| `Basic_StringToNum` | 将文本转换为数字 |
| `Basic_Delay_V2` | 等待 |
| `Basic_ConvertInt` | 数值转为整数类型 |
| `Basic_RandomInt` | 生成随机数 |
| `Basic_IsNumber` | 判断数据是否为数值 |
| `Basic_ConvertBool` | 数据转为布尔类型 |
| `Basic_Type` | 获取数据类型 |
| `Basic_Dodecryption` | 设置密文变量 |
| `Basic_IsNull` | 判断数据是否为空 |
| `Basic_DebugOutput` | 调试输出 |
| `Basic_BusinessOrderCount` | 业务办理计数 |
| `Basic_ConvertNumber` | 数据转为数值类型 |
| `Basic_GetProcessInfo` | 获取流程信息 |
| `Basic_ConvertString` | 将数字转换为文本 |
| `Basic_Delay` | 流程延迟执行 |
| `Basic_IsArray` | 判断数据是否为数组 |
| `Basic_Clone` | 复制数据 |
| `Basic_JudgeDataType` | 判断数据类型 |
| `Basic_DataTypeConversion` | 转换数据类型 |
| `Bivartable_CreateDfV1` | 创建数据表格 |
| `Bivartable_GetMaxValue` | 获取最大值 |
| `Bivartable_DelDfColRow` | 删除行/列 |
| `Bivartable_ReadDfData_V1` | 读取数据 |
| `Bivartable_GetMinValue` | 获取最小值 |
| `Bivartable_HandleDfMissValue` | 缺失值处理 |
| `Bivartable_ColumnOperationDf` | 两列运算 |
| `Bivartable_SortDf` | 排序数据表格 |
| `Bivartable_ReadTable` | 读取数据表内容 |
| `Bivartable_DoCreate` | 创建二维表 |
| `Bivartable_WriteDfData` | 写入数据 |
| `Bivartable_FilterDf` | 筛选数据表格 |
| `Bivartable_SummaryCalculationDf` | 按列汇总计算 |
| `Bivartable_DoSort` | 排序 |
| `Bivartable_DoFilter` | 表格过滤 |
| `Bivartable_InsertDfData` | 插入数据 |
| `Bivartable_ReadDfIndexColumns` | 读取行名/列名 |
| `Bivartable_ReadDfShape` | 读取行数/列数 |
| `Bivartable_DfWriteToFile` | 写入到文件 |
| `Bivartable_DoSave` | 保存二维表至Excel |
| `Bivartable_PivotDf` | 生成数据透视表 |
| `Bivartable_GetSumValue` | 获取统计总和 |
| `Bivartable_GetAvgValue` | 获取平均值 |
| `Bivartable_SpliceColData` | 拼接两列数据 |
| `Bivartable_Dtype` | 列数据类型转换 |
| `Bivartable_ModifyDfName` | 修改行名/列名 |
| `Bivartable_ModifyColName` | 修改列名 |
| `Bivartable_DeleteDuplicateRow` | 删除重复行 |
| `Bivartable_MergeDf` | 合并数据表格 |
| `Bivartable_DoOpenExcel` | 基于Excel创建二维表 |
| `Bivartable_DoMerge` | 合并二维表 |
| `Bivartable_WriteTable` | 数据表写入内容 |
| `Bivartable_DoJoint` | 拼接列 |
| `Bivartable_DoCal` | 列计算 |
| `Clipboard_SetImage` | 设置剪切板图片 |
| `Clipboard_SetTextV1` | 将文本添加到剪切板 |
| `Clipboard_GetText` | 取剪切板文本 |
| `Clipboard_SaveImageV1` | 获取剪切板文件 |
| `Clipboard_GetTextV1` | 获取剪切板文本 |
| `Clipboard_ClearClipboard` | 清空剪切板 |
| `Clipboard_SetImageV1` | 将文件添加到剪切板 |
| `Clipboard_SetText` | 设置剪切板文本 |
| `Clipboard_SaveImage` | 取剪切板图片 |
| `Collection_Create` | 创建集合 |
| `Collection_HasItem` | 判断集合元素存在 |
| `Collection_Count` | 取集合大小 |
| `Collection_Remove` | 删除集合元素 |
| `Collection_Add` | 添加集合元素 |
| `CSV_ReadNew` | 读取内容 |
| `CSV_WriteNew` | 写入内容 |
| `CustomConfig_GetConfig` | 自定义配置 |
| `CV_GetText` | 获取文本 |
| `CV_SetText` | 文本输入 |
| `Element_SetValue_V1` | 文本输入 |
| `Element_SetValueGround` | 文本输入 |
| `CV_MouseHover` | 移动到元素上 |
| `CV_MouseClick` | 元素点击 |
| `Element_Click_V1` | 元素点击 |
| `Element_ClickGround` | 元素点击 |
| `Database_BatchSQLExecute` | 批量执行SQL语句 |
| `Database_DBConnect` | 连接数据库 |
| `Database_SingleSQLQuery` | 执行单SQL查询 |
| `Database_BatchInsertExecute` | SQL批量插入数据 |
| `Database_AllSQLQuery_V1` | 执行全SQL查询 |
| `Database_CloseDBConnect` | 关闭连接 |
| `Mail_RecvClose` | 关闭连接 |
| `Database_SQLExecute` | 执行SQL语句 |
| `Databook_WriteContent` | 写入内容至数据表 |
| `Databook_DeleteColumn` | 删除数据表格列 |
| `Databook_Export` | 导出数据表格 |
| `Databook_GetColumnInfo` | 获取列信息 |
| `Databook_ReadContent` | 读取数据表格内容 |
| `Databook_Clear` | 清空数据表格 |
| `Databook_SetColumnInfo` | 设置列描述 |
| `Databook_Import` | 导入数据表格 |
| `Databook_GetRowAndColCount` | 读取数据表格总行列数 |
| `Databook_DeleteRow` | 删除数据表格行 |
| `DataService_Correct` | 文本纠错 |
| `DataService_AgentCall` | 调用智能体 |
| `DataService_AIGenTxt` | AI生成文本 |
| `DataService_WorkingDay` | 判断是否工作日 |
| `DataService_Diff` | 文本比对 |
| `DataService_LargeModel` | 大模型 |
| `DataService_Review` | 文本审核 |
| `DataService_ChatBot` | chatbot对话机器人 |
| `DataService_KeyWord` | 关键词抽取 |
| `DataService_ZBrain` | 自定义AI模型 |
| `Dialog_SaveFile` | 保存文件对话框 |
| `Dialog_CsInputBox` | 输入对话框 |
| `Dialog_GetConfig` | 输入输出对话框 |
| `Dialog_GetConfigV7` | 人机交互表单 |
| `Dialog_Notice_V2` | 消息通知 |
| `Dialog_OpenFile` | 打开文件对话框 |
| `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_OpenFiles` | 打开文件对话框(多选) |
| `Dialog_CsMsgBox` | 消息确认对话框 |
| `Dict_Create` | 创建字典 |
| `Dict_Delete` | 删除键值对 |
| `Dict_Items` | 获取字典所有键值 |
| `Dict_Values` | 获取字典所有值 |
| `Dict_Clear` | 清空字典 |
| `Dict_GetItem` | 获取字典值 |
| `Dict_Keys` | 获取字典所有键 |
| `Dict_Update` | 合并字典 |
| `Dict_SetItem` | 设置字典值 |
| `Element_SetValuePassword_V1` | 输入密码框 |
| `Element_Region2_v3` | 获取元素坐标 |
| `Element_RegionGround` | 获取元素坐标 |
| `Element_SelectOperation_v3` | 下拉框操作 |
| `Element_Sibling_V2` | 获取兄弟元素 |
| `Element_Children_V2` | 获取子元素 |
| `Element_ChildrenGround` | 获取子元素 |
| `Element_ElementAarray` | 获取相似元素数组 |
| `Element_Exists` | 元素判断 |
| `Element_ExistsGround` | 元素判断 |
| `Element_GetCheckGround` | 获取勾选框状态值 |
| `Element_GetCheck` | 获取勾选框状态值 |
| `Element_SetAttribute_v2` | 设置网页元素属性 |
| `Element_SetAttributeGround` | 设置网页元素属性 |
| `Element_WaitElement_v5` | 等待元素 |
| `Element_WaitElementGround` | 等待元素 |
| `Element_ScrollIntoView_v2` | 滚动页面 |
| `Element_ScrollIntoViewGround` | 滚动页面 |
| `Element_MouseTo_V4` | 鼠标移动到元素上 |
| `Element_MouseToGround` | 鼠标移动到元素上 |
| `Element_Click_V6` | 点击界面元素 |
| `Element_SetCheckGround` | 设置勾选框状态 |
| `Element_SetCheck` | 设置勾选框状态 |
| `Element_Parent_V2` | 获取父元素 |
| `Element_SetValue_V3` | 输入文本框 |
| `Element_StopAutoClosePopup` | 停止智能弹窗侦测 |
| `Element_GetButtonEnable_v4` | 判断元素是否可点击 |
| `Element_SetValue_V6` | 输入文本 |
| `Element_GetText_V5` | 获取界面元素文本 |
| `Element_GetValue` | 获取元素文本 |
| `Element_GetValueGround` | 获取元素文本 |
| `Element_SimilarElementList_V2` | 获取相似元素列表 |
| `Element_Shot_V2` | 元素截图 |
| `Element_ShotGround` | 元素截图 |
| `Element_CheckBoxOperation_v3` | 勾选框操作 |
| `Element_GetLineBarChartData` | 获取折线图/柱状图数据 |
| `Element_GetAttribute_v3` | 获取网页元素属性 |
| `Element_GetAttributeGround` | 获取网页元素属性 |
| `Element_GetSelect` | 获取下拉框选项值 |
| `Element_GetSelectGround` | 获取下拉框选项值 |
| `Element_SetValuePassword_V6` | 输入密码 |
| `Element_GetText_V1` | 获取区域文字 |
| `Element_GetElementNew` | 获取元素对象 |
| `Element_GetElementInfo_V1` | 获取元素信息 |
| `Element_ExistsV1` | 判断元素存在 |
| `Element_GetPieChartData` | 获取饼状图数据 |
| `Element_GetTableData` | 获取SAP表格数据 |
| `Element_StartAutoClosePopup` | 启动智能弹窗侦测 |
| `Element_SetSelectGround` | 设置下拉框选择项 |
| `Element_SetSelect` | 设置下拉框选择项 |
| `Element_GetHtml` | 获取网页元素源码 |
| `Element_GetButtonEnableGround` | 元素是否可点击 |
| `Element_FileUploader` | 上传文件 |
| `WebBrowser_Upload` | 上传文件 |
| `Excel_WriteColumnNew` | 写入列 |
| `Excel_Cs_SetCellFormat_V3` | 设置单元格格式 |
| `Excel_Cs_ReadContentV4` | 读取Excel内容 |
| `Excel_Cs_AutoFillV2` | 自动填充 |
| `Excel_NewExcel` | 新建excel |
| `Excel_Cs_CopyV2` | 复制Sheet |
| `Excel_WriteRangeNew` | 写入区域 |
| `Excel_Cs_DeleteColumn` | 删除列 |
| `Excel_Cs_OpenV4` | 打开Excel |
| `Excel_PasteRange` | 粘贴区域 |
| `Excel_OpenExcelNew` | 打开excel |
| `Excel_CopyRange` | 复制区域 |
| `Excel_Cs_ClearRangeV4` | 清除区域 |
| `Excel_Cs_InsertColumnV3` | 插入列 |
| `Excel_Cs_ReadRowCount` | 读取行数 |
| `Excel_Cs_ReadColumnCount` | 读取列数 |
| `Excel_Cs_Group` | 组合行列 |
| `Excel_ReadCellNew` | 读取单元格 |
| `Excel_WriteCellNew` | 写入单元格 |
| `Excel_Cs_SetRangeFontV2` | 设置区域字体颜色 |
| `Excel_Cs_DeletePictureV2` | 删除图片 |
| `Excel_Cs_NewV5` | 新建Excel |
| `Excel_GetColDataNew` | 读取列 |
| `Excel_GetSheetsName` | 获取sheet名 |
| `Excel_Cs_GetEmptyCell` | 获取第一个空白单元格 |
| `Excel_Cs_GetEditRange` | 获取已编辑区域 |
| `Excel_Cs_InsertRowV3` | 插入行 |
| `Excel_Cs_RefreshPivotTable` | 刷新Excel数据透视表 |
| `Excel_Cs_SetHeightV1` | 设置行高 |
| `Excel_Cs_Encrypt` | 加密Excel |
| `Excel_Cs_Move` | 移动Sheet |
| `Excel_Cs_DeleteDuplicates` | 删除重复 |
| `Excel_Cs_DeleteRangeV3` | 删除区域 |
| `Excel_Cs_ExcelFilter` | Excel数据筛选 |
| `Excel_Cs_GetPath` | 获取Excel路径 |
| `Excel_Cs_DeleteRow` | 删除行 |
| `Excel_Cs_Active` | 激活Sheet |
| `Excel_ReadRangeNew` | 读取区域 |
| `Excel_Cs_ConvertColumn` | 列名转换 |
| `Excel_Cs_InsertPictureV2` | 插入图片 |
| `Excel_Cs_Insert` | 插入Sheet |
| `Excel_Cs_Merge` | 合并Sheet |
| `Excel_Cs_ReadCellFormula` | 获取单元格公式 |
| `Excel_Save` | 保存excel |
| `Excel_Cs_InsertPivotView` | 新建透视图 |
| `Excel_Cs_Filter` | 数据筛选 |
| `Excel_SetCellFontColor` | 单元格字体颜色 |
| `Excel_Cs_CopyRangeV3` | 复制表格内容 |
| `Excel_Cs_SetWidthV1` | 设置列宽 |
| `Excel_SetRangeColor` | 区域颜色 |
| `Excel_Cs_Interop` | Excel动态调用 |
| `Excel_Cs_InsertPivotTable` | 新建Excel数据透视表 |
| `Excel_Cs_RunMacroV2` | 执行宏 |
| `Excel_Cs_FindV2` | Excel查找/替换 |
| `Excel_Cs_SaveAs` | 另存为Excel |
| `Excel_Loop` | excel内容循环 |
| `Excel_Cs_GetLastEmptyCell` | 获取尾部第一个空白单元格 |
| `Excel_WriteRowNew` | 写入行 |
| `Excel_GetRowDataNew` | 读取行 |
| `Excel_SetRangeFontColor` | 区域字体颜色 |
| `Excel_GetColumnId` | 获取Excel列标识 |
| `Excel_Cs_SetCellFont` | 设置单元格字体颜色 |
| `Excel_Cs_Delete` | 删除Sheet |
| `Excel_ActiveSheet` | 激活sheet |
| `Excel_Cs_Export` | 导出Excel为PDF |
| `Excel_Cs_MergeCellsV2` | 合并单元格 |
| `Excel_Cs_ShowTableStyle` | 设置Excel表格样式 |
| `Excel_Cs_CloseV2` | 关闭Excel |
| `Excel_Cs_SelectRangeV3` | 选中区域 |
| `Excel_Cs_ClearExcelFilter` | 清除Excel数据筛选 |
| `Excel_Close` | 关闭excel |
| `Excel_Cs_Rename` | 重命名Sheet |
| `Excel_Cs_SetCellBackground` | 设置单元格背景颜色 |
| `Excel_Cs_Save` | 保存Excel |
| `Excel_SetCellColor` | 单元格背景颜色 |
| `Excel_Cs_GetName` | 获取Sheet名 |
| `Excel_Cs_PastRangeV2` | 粘贴表格内容 |
| `Excel_Cs_ColumnSplitV2` | Excel分列 |
| `Excel_Cs_SortV1` | Excel数据排序 |
| `Excel_Cs_ExcelAddin` | 数据处理 |
| `Excel_Cs_ReadCellBackground` | 获取单元格背景色 |
| `Excel_RenameSheet` | 重命名sheet |
| `Excel_Cs_FilterPivotTable` | 筛选Excel数据透视表 |
| `Excel_SaveAs` | 另存excel |
| `Excel_Cs_Hide` | 隐藏/显示行列 |
| `Excel_Cs_SetSlicer` | Excel切片器 |
| `Excel_Cs_WriteContent` | 写入Excel内容 |
| `Excel_Cs_EnableExcelFilter` | 取消Excel数据筛选 |
| `Excel_Cs_SetRangeBackgroundV2` | 设置区域背景颜色 |
| `Excel_Cs_LoadV2` | 获取已打开Excel |
| `Excel_Cs_RefreshAll` | 刷新Excel数据 |
| `Excel_CopySheet` | 复制sheet |
| `Excel_CreateSheet` | 创建sheet |
| `Excel_ExportRangeAsPicture` | 导出区域为图片 |
| `Excel_Cs_Search` | 查找/替换数据 |
| `Excel_DeleteSheet` | 删除sheet |
| `ExceptionUtil_Throws` | 抛出异常 |
| `File_Rename` | 重命名文件 |
| `File_Delete` | 删除文件 |
| `File_DecompressFile` | 解压文件 |
| `File_GetFileList_V1` | 获取文件列表 |
| `File_File_Move` | 移动文件 |
| `File_MoveFolder` | 移动文件夹 |
| `File_Write_V1` | 写入文件 |
| `File_PathSize` | 取文件大小 |
| `File_MakeDir` | 创建文件夹 |
| `File_Copy_V1` | 复制文件/文件夹 |
| `File_GetFileInfo` | 获取文件信息 |
| `File_GetFolderList_V1` | 获取文件夹列表 |
| `File_ReadXml` | 读取xml文件 |
| `File_OpenFile` | 打开文件夹 |
| `File_Read` | 读取文件 |
| `File_Rename_V1` | 重命名文件/文件夹 |
| `File_CreateFile` | 新建文件 |
| `File_DeleteFile` | 删除文件/文件夹 |
| `File_PathSizeNew` | 获取文件大小 |
| `File_CreateFolder` | 生成上传路径 |
| `File_Copy` | 复制文件 |
| `File_CompressFile` | 压缩文件 |
| `File_FileExists` | 判断文件存在 |
| `File_PathExists` | 判断路径 |
| `File_Move` | 移动文件/文件夹 |
| `File_EmptyFolder` | 清空文件夹 |
| `File_WaitFile` | 等待文件 |
| `FileTypeConversion_Office2pdf` | OFFICE文档转PDF |
| `FileTypeConversion_Pdf2image2` | PDF转图片 |
| `FTP_Download` | 下载文件/文件夹(FTP) |
| `FTP_Rename` | 重命名文件/文件夹(FTP) |
| `FTP_Delete` | 删除文件/文件夹(FTP) |
| `FTP_Disconnect` | 断开FTP连接 |
| `FTP_Cwd` | 切换工作目录 |
| `FTP_GetFileList` | 获取文件/文件夹(FTP) |
| `FTP_CreateDir` | 创建文件夹(FTP) |
| `FTP_Exist` | 判断文件存在(FTP) |
| `FTP_Upload` | 上传文件/文件夹(FTP) |
| `FTP_Connect` | 建立FTP连接 |
| `GoogleTable_GetAllData` | 获取列信息(Google Sheets) |
| `GoogleTable_GetAllSheetsName` | 获取Sheet名(Google Sheets) |
| `GoogleTable_WriteToSheetRegionColumns` | 写入列(Google Sheets) |
| `GoogleTable_WriteToSheetRegionRows` | 写入行(Google Sheets) |
| `GoogleTable_WriteToSheetCell` | 写入单元格(Google Sheets) |
| `GoogleTable_WriteToSheetCellFormula` | 写入单元格公式(Google Sheets) |
| `GoogleTable_GetSheetRowCount` | 获取行数(Google Sheets) |
| `GoogleTable_DownloadSheets` | 导出Sheet(Google Sheets) |
| `GoogleTable_CopyPasteSheet` | 复制粘贴(Google Sheets) |
| `GoogleTable_GetColumnsByTableTitle` | 按表头读取列(Google Sheets) |
| `GoogleTable_ChangeSheet` | 激活Sheet(Google Sheets) |
| `GoogleTable_RenameSheet` | 重命名Sheet(Google Sheets) |
| `GoogleTable_WriteToSheetRegionregion` | 写入区域(Google Sheets) |
| `Http_Post_V3` | POST请求 |
| `Http_Get_V2` | GET请求 |
| `Http_Http_Request` | HTTP 请求 |
| `Image_Click_V2` | 点击图像 |
| `Image_Move_V2` | 鼠标移动到图像上 |
| `Image_Screenshot` | 屏幕截图 |
| `Image_Base64Decode` | 图片base64编码转二进制 |
| `Image_Find_V1` | 查找图像 |
| `Image_Base64Encode` | 图片转base64编码 |
| `Image_Wait_V1` | 等待图像 |
| `Image_Exists_V1` | 判断图像 |
| `Image_Location` | 图像位置 |
| `Image_Location_V1` | 获取图像位置 |
| `Image_CropImg` | 截取图像 |
| `Image_Click_V3` | 鼠标点击图像 |
| `Image_RelativePosition` | 对比图像相对位置 |
| `Image_Move_V1` | 移动到图像 |
| `Ini_DeleteKey` | 删除INI文件键 |
| `Ini_Write` | 写入INI文件键值 |
| `Ini_ListSection` | 枚举INI文件小节 |
| `Ini_DeleteSection` | 删除INI文件小节 |
| `Ini_ListKey` | 枚举INI文件键 |
| `Ini_Read` | 读取INI文件键值 |
| `JSON_Stringify` | 将JSON转换为文本 |
| `JSON_Parse` | 将文本转换为JSON |
| `Keyboard_PressNew` | 键盘模拟按键 |
| `Keyboard_DriveSend_V2` | 键盘驱动输入 |
| `Keyboard_Press` | 键盘按键 |
| `Keyboard_Send_V1` | 键盘输入 |
| `Keyboard_EnableManualOperation` | 开启模拟人工操作 |
| `Keyboard_DisableManualOperation` | 关闭模拟人工操作 |
| `List_DeleteList` | 删除列表项 |
| `List_GetIndex` | 获取列表项的位置 |
| `List_ListMerge` | 列表合并 |
| `List_ClearList` | 清空列表 |
| `List_ListLength` | 获取列表长度 |
| `List_ListSorted` | 列表排序 |
| `List_ListDuplication` | 列表去重 |
| `List_InsertList` | 新增列表项 |
| `List_ListIntersection` | 获取两列表的重复项 |
| `List_CreateList` | 创建列表 |
| `List_GetListItem` | 获取指定位置项的数据 |
| `List_SetListItem` | 修改指定位置项的数据 |
| `Log_Error` | 打印错误日志 |
| `Log_Info` | 打印普通日志 |
| `Mail_UnmarkSeen` | 标为未读 |
| `Mail_DoReplyMail_v1` | 回复邮件(Outlook) |
| `Mail_RecvConnect` | 连接邮箱 |
| `Mail_RecvMailGet` | 取第几封邮件 |
| `Mail_ExOperateMail` | 操作邮件 |
| `Mail_OperateMail` | 操作邮件 |
| `Mail_RecvGetList` | 邮件筛选 |
| `Mail_ReplyMail_v2` | 回复邮件 |
| `Mail_DoReplyMail` | 回复邮件 |
| `Mail_Send_V1` | 发送邮件 |
| `Mail_DoSendMail` | 发送邮件 |
| `Mail_ExSendMail` | 发送邮件 |
| `Mail_FindMail_v1` | 查找邮件 |
| `Mail_ExSearchMail` | 查找邮件 |
| `Mail_DoMoveMail` | 移动邮件 |
| `Mail_ExConnect` | 连接Exchange邮箱 |
| `Mail_RecvCount` | 取收件箱邮件数 |
| `Mail_DoGetMailMessage_v2` | 获取邮件(Outlook) |
| `Mail_DoGetMailMessage_v1` | 查找邮件(Outlook) |
| `Mail_FindMail_v2` | 获取邮件 |
| `Mail_DoGetMailMessage` | 查看邮件 |
| `Mail_MarkSeen` | 标为已读 |
| `Mail_DoMoveMail_v1` | 操作邮件(Outlook) |
| `Mail_DoSendMail_v1` | 发送邮件(Outlook) |
| `Math_Cos` | 余弦值 |
| `Math_Tan` | 正切值 |
| `Math_Scale` | 进制转换 |
| `Math_Round` | 四舍五入 |
| `Math_Pow` | x的y次幂 |
| `Math_Int` | 取整 |
| `Math_Sqrt` | 平方根 |
| `Math_Oct` | 转8进制 |
| `Math_Sin` | 正弦值 |
| `Math_Abs` | 绝对值 |
| `Math_Hex` | 转16进制 |
| `Math_Exp` | e的n次幂 |
| `Math_Sgn` | 取正负符号 |
| `MessageNotification_Feishu_Message` | 发送消息至飞书 |
| `MessageNotification_DingTalk_Message` | 发送消息至钉钉 |
| `MessageNotification_WXWork_Message` | 发送消息至企业微信 |
| `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_DragToNew` | 鼠标拖拽 |
| `Mouse_RollTo` | 鼠标滚动 |
| `Mouse_MoveToV2` | 移动鼠标 |
| `NLP_BatchAnalyseProductReview` | 商品评论分析(批量) |
| `NLP_KeyWordsAnalysis` | 关键词 |
| `NLP_EmotionalAnalysis` | 情感分析 |
| `NLP_SplitText` | 实体抽取 |
| `NLP_AnalyseProductReview` | 商品评论分析 |
| `NLP_SemanticSimilarity` | 语义相似度 |
| `NLP_SplitWords` | 分词 |
| `NLP_PerspectiveAnalysis` | 观点分析 |
| `NLP_XunFei_NLPBaseServer` | 第三方NLP模型 |
| `NLP_XunFei_MachineTranslation` | 机器翻译 |
| `NLP_XunFei_FeelingAnalysis` | 第三方情感分析模型 |
| `NLP_XunFei_KeywordExtraction` | 关键字提取 |
| `OCR_MergeCardOCR` | 卡证文字识别 |
| `OCR_CalculationCode` | 算术验证码 |
| `OCR_IdCardV2` | OCR身份证 |
| `OCR_BusinessCardV2` | OCR名片 |
| `OCR_VehicleInvoiceV2` | OCR机动车发票 |
| `OCR_MergeOtherOCR` | 其他场景文字识别 |
| `OCR_BusinessLicenseV3` | OCR营业执照(自研) |
| `OCR_PassportV2` | OCR护照 |
| `OCR_MergeGeneralOCR` | 通用文字识别 |
| `OCR_KeyValueIdentification` | 识别键值对结构 |
| `OCR_AmountIdentification` | 识别价格金额 |
| `OCR_GeneralTextV2` | OCR高精度文本 |
| `OCR_VatInvoiceV2` | OCR增值税发票 |
| `OCR_DrivingLicenseV2` | OCR行驶证 |
| `OCR_AccountPageV2` | OCR户口页 |
| `OCR_PlateLicenseV2` | OCR车牌 |
| `OCR_BankCardV2` | OCR银行卡 |
| `OCR_BankCardV3` | OCR银行卡(自研) |
| `OCR_IdCardV3` | OCR身份证(自研) |
| `OCR_VatInvoiceV3` | OCR增值税发票(自研) |
| `OCR_MergePaperOCR` | 票据文字识别 |
| `OCR_DriverLicenseV2` | OCR驾驶证 |
| `OCR_RealEstateCertV2` | OCR不动产权证 |
| `OCR_TrainTicketV2` | OCR火车票 |
| `OCR_TaxiInvoiceV2` | OCR出租车发票 |
| `OCR_OfficialSealV2` | OCR公章 |
| `OCR_MergeCarOCR` | 汽车场景文字识别 |
| `OCR_MergeEducationOCR` | 教育场景文字识别 |
| `OCR_HouseCertV2` | OCR房产证 |
| `OCR_CommonEnglishNumbersCode` | 普通验证码 |
| `OCR_BusinessLicenseV2` | OCR营业执照 |
| `OCR_VinV2` | OCR车辆vin码 |
| `OcrOffline_GetOcrTextNew` | 图像OCR识别 |
| `OcrOffline_GetOcrTable` | 获取表格数据(OCR) |
| `OcrOffline_GetOcrTextPosition` | 图像OCR识别(含坐标) |
| `PDF_GetPdfInformation` | 获取 PDF 文档信息 |
| `PDF_SetPdfWaterMarkV1` | PDF 加水印 |
| `PDF_PdfEncryption` | 加密 PDF 文档 |
| `PDF_GetPdfImg` | 提取 PDF 图片 |
| `PDF_GetPdfPageNum` | 获取 PDF 总页数 |
| `PDF_ConcatPdfV1` | 合并 PDF 文档 |
| `PDF_GetPdfTable` | 读取 PDF 表格 |
| `PDF_PdfToImg` | PDF 转图片 |
| `PDF_CreateNewPdf` | 生成新的 PDF 文档 |
| `PDF_GetPdfText` | 读取 PDF 文本 |
| `Regex_Test` | 匹配判断 |
| `Regex_FindAll` | 全部查找 |
| `Regex_FindNew_1` | 文本内容提取(指定) |
| `Regex_FindStr` | 文本内容提取 |
| `Regex_FindNew` | 指定查找 |
| `Regex_FindAllNew` | 文本内容提取(全部) |
| `Regex_TestNew` | 判断文本匹配 |
| `Regex_FindStrV1` | 提取文本内容 |
| `Scenario_TextComparison` | 文档比对 |
| `ScreenControl_Screenlock` | 屏幕锁屏 |
| `ScreenControl_ScreenUnlock` | 唤醒屏幕(非登录解锁) |
| `StrUtil_InStr` | 查找 |
| `StrUtil_ListToString` | 列表聚合成文本 |
| `StrUtil_CharCode` | 将ASCII码转换为文本 |
| `StrUtil_CutOutStrV1` | 截取文本 |
| `StrUtil_CreateRandomStr` | 创建随机文本 |
| `StrUtil_SplitStr` | 分割文本为列表 |
| `StrUtil_Split` | 分割字符串 |
| `StrUtil_CaseChange` | 转换大小写 |
| `StrUtil_AddToStr` | 追加文本 |
| `StrUtil_Rtrim` | 裁剪右侧空格 |
| `StrUtil_DelTrim` | 去除文本两端空格 |
| `StrUtil_Trim` | 裁剪两侧空格 |
| `StrUtil_LowerCase` | 字母转小写 |
| `StrUtil_LookUpStr` | 查找文本 |
| `StrUtil_ReverseStr` | 反转文本 |
| `StrUtil_UpperCase` | 字母转大写 |
| `StrUtil_Mid` | 取中间内容 |
| `StrUtil_CharAsc` | 将文本转换为ASCII码 |
| `StrUtil_CompleteStr` | 补齐文本 |
| `StrUtil_Replace` | 替换文本 |
| `StrUtil_Left` | 取左侧内容 |
| `StrUtil_Ltrim` | 裁剪左侧空格 |
| `StrUtil_Len` | 获取文本长度 |
| `StrUtil_Right` | 取右侧内容 |
| `System_GetEnv` | 获取环境变量 |
| `System_ReadCredential` | 获取Windows凭据 |
| `System_GetTempPath` | 获取临时文件夹路径 |
| `System_GetHomePath` | 获取用户文件夹路径 |
| `System_GetSystemPath` | 获取系统文件夹路径 |
| `System_SetEnv` | 设置环境变量 |
| `System_GetInputMethodState` | 获取输入法状态 |
| `System_ConnectRemoteDesktop` | 连接远程桌面 |
| `System_AddCredential` | 添加Windows凭据 |
| `System_SetInputMethodState` | 设置输入法状态 |
| `System_CloseRemoteDesktop` | 关闭远程连接 |
| `System_ScreenSaverShowV1` | 开启屏保 |
| `System_ExecuteCommandV1` | 执行命令行 |
| `System_DeleteCredential` | 删除Windows凭据 |
| `System_ScreenSaverStop` | 关闭屏保 |
| `System_PlaySound` | 播放音频 |
| `Time_GetDateV2` | 获取日期时间 |
| `Time_CalDate` | 计算时长 |
| `Time_WeekDay` | 取星期 |
| `Time_Day` | 取第几天 |
| `Time_Date` | 取日期 |
| `Time_FormatNew` | 将时间戳转换为日期/时间 |
| `Time_Hour` | 取时 |
| `Time_DateOffset_V1` | 增加/减少时间 |
| `Time_GetDatetimeObj` | 获取日期时间信息 |
| `Time_Format` | 时间戳转时间 |
| `Time_DatetimeToTimestamp` | 时间转时间戳 |
| `Time_TimeStamp` | 取时间戳 |
| `Time_TimeTypeConversion_V1` | 转换日期时间格式 |
| `Time_DateToTimestamp` | 将日期/时间转换为时间戳 |
| `Time_Month` | 取月份 |
| `Time_Time` | 取时分秒 |
| `Time_Year` | 取年份 |
| `Time_TimeTypeConversion` | 日期时间格式转换 |
| `Time_Now` | 取时间 |
| `Time_Second` | 取秒 |
| `Time_Minute` | 取分 |
| `WebBrowser_Switch_V1` | 切换页面 |
| `WebBrowser_SetScrollPos_V1` | 设置滚动条位置 |
| `WebBrowser_GetTAB_V2` | 获取网页 Tab 对象 |
| `WebBrowser_SaveAs_V1` | 下载文件(点击按钮) |
| `WebBrowser_GetWeblistening` | 获取监听结果 |
| `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_RunJsInIeBySelector_v3` | 执行JS |
| `WebBrowser_RunJsInIeBySelectorGround` | 执行JS |
| `WebBrowser_CreateV3` | 打开网页 |
| `WebBrowser_Reload` | 刷新 |
| `WebBrowser_CloseWeb_V2` | 关闭网页 |
| `WebBrowser_Stop` | 停止网页加载 |
| `WebBrowser_GetDocument` | 获取网页源码 |
| `WebBrowser_GetCookie` | 获取Cookies(指定) |
| `WebBrowser_DownFile_V1` | 下载文件(指定链接) |
| `WebBrowser_DownloadFile_V1` | 下载文件 |
| `WebBrowser_GetCookieNew` | 获取完整Cookies |
| `WebBrowser_GetBrowserPicV1` | 浏览器截图 |
| `WebBrowser_GetTAB` | 获取Tab页 |
| `WebBrowser_Close_V1` | 关闭浏览器 |
| `WebBrowser_Maximize` | 最大化浏览器 |
| `WebBrowser_GoForward` | 前进 |
| `WebBrowser_SetCookies` | 设置Cookies |
| `WebBrowser_StartWeblistening` | 启动网页监听 |
| `WebBrowser_Wait` | 等待网页加载 |
| `WebBrowser_UploadDialog` | 处理上传对话框 |
| `WebBrowser_DataCrawler` | 爬取数据 |
| `WebBrowser_StopWeblistening` | 停止网页监听 |
| `WebBrowser_SetCookies_V1` | 设置Cookie |
| `WebBrowser_GetTitle` | 获取Title |
| `WebBrowser_GetScrollPos_V1` | 获取滚动条位置 |
| `WebBrowser_GoBack` | 后退 |
| `WebBrowser_GoUrl` | 跳转网页 |
| `WebBrowser_BatchDataCrawl` | 批量数据采集(beta) |
| `WebBrowser_GetUrl` | 获取URL |
| `WebBrowser_GetCookies` | 获取Cookies |
| `WebBrowser_Minimize` | 最小化浏览器 |
| `Window_SizeWindow_v1` | 调整窗口大小 |
| `Window_SetWindowState_v1` | 设置窗口状态 |
| `Window_GetWindowRect` | 获取窗口范围 |
| `Window_CloseWindow` | 关闭软件窗口 |
| `Window_GetActive` | 获取活动窗口 |
| `Window_ActiveWindow_v1` | 激活窗口 |
| `Window_WindowExists` | 判断窗口存在 |
| `Window_Get` | 获取窗口 |
| `Window_SetPos` | 设置窗口 |
| `Window_GetWindow_v1` | 获取窗口对象 |
| `Window_CloseWindow_v1` | 关闭窗口 |
| `Window_GetWindowMessage_v1` | 获取窗口信息 |
| `Window_MoveWindow_v1` | 移动窗口位置 |
| `Window_ActiveWindow` | 激活软件窗口 |
| `Window_GetScreenSize` | 获取屏幕尺寸 |
| `Window_SetWindowShow` | 设置窗口是否显示 |
| `Window_WindowList` | 获取窗口对象列表 |
| `Window_GetRect` | 窗口属性 |
| `Window_MaximumSize` | 窗口最大化 |
| `Window_WaitWindow` | 等待窗口 |
| `Word_ReadSelect` | 读取选中文本 |
| `Word_SaveAs` | Word另存为 |
| `Word_SetFontName` | 设置字体 |
| `Word_CutSelect` | 剪切 |
| `Word_ReadAll` | 读取Word文本 |
| `Word_InsertPicture` | 插入Word图片 |
| `Word_SetFontStyle` | 设置字体样式 |
| `Word_New` | 新建word |
| `Word_SetAlign` | 设置对齐方式 |
| `Word_Save` | 保存Word |
| `Word_ReadTable` | 读取Word表格 |
| `Word_New_V1` | 新建Word文档 |
| `Word_Open` | 打开Word文档 |
| `Word_SelectRow` | 选择行 |
| `Word_GetPath` | 获取Word路径 |
| `Word_DeleteSelect` | 删除 |
| `Word_SetPosition` | 设置光标位置 |
| `Word_Write` | 写入文字至Word |
| `Word_PositionToSelect` | 查找文本后设置光标位置 |
| `Word_Close` | 关闭Word |
| `Word_MovePosition` | 移动光标位置 |
| `Word_SetFontColor` | 设置字体颜色 |
| `Word_CopySelect` | 复制 |
| `Word_PasteSelect` | 粘贴 |
| `Word_Replace` | 替换 |
| `Word_WriteTable` | 插入Word表格 |
| `Word_Enter` | 回车 |
| `Word_SetFontSize` | 设置字体大小 |
| `Word_SelectAll` | 全选 |
| `Word_InsertPage` | 插入分页符 |
## 隐藏重复能力
| 隐藏 ID | 保留主能力 | 名称 |
|---|---|---|
| `Basic_SetVariable` | `Basic_SetVariable_V1` | 设置变量 |
| `App_Kill` | `App_KillV1` | 关闭应用程序 |
| `Basic_Delay_V1` | `Basic_Delay_V2` | 等待 |
| `Basic_Rnd` | `Basic_RandomInt` | 生成随机数 |
| `Bivartable_CreateDf` | `Bivartable_CreateDfV1` | 创建数据表格 |
| `Bivartable_ReadDfData` | `Bivartable_ReadDfData_V1` | 读取数据 |
| `CSV_Read` | `CSV_ReadNew` | 读取内容 |
| `CSV_Write` | `CSV_WriteNew` | 写入内容 |
| `Element_SetValue` | `Element_SetValue_V1` | 文本输入 |
| `Element_Click` | `Element_Click_V1` | 元素点击 |
| `Database_AllSQLQuery` | `Database_AllSQLQuery_V1` | 执行全SQL查询 |
| `Dialog_InputBox` | `Dialog_CsInputBox` | 输入对话框 |
| `Dialog_GetConfigV5` | `Dialog_GetConfigV7` | 人机交互表单 |
| `Dialog_Notice_V1` | `Dialog_Notice_V2` | 消息通知 |
| `Dialog_Notice` | `Dialog_Notice_V2` | 消息通知 |
| `Dialog_GetConfigV6` | `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_GetConfigV4` | `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_GetConfigV3` | `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_GetConfigV2` | `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_GetConfigV1` | `Dialog_GetConfigV8` | 自定义对话框 |
| `Dialog_MsgBox` | `Dialog_CsMsgBox` | 消息确认对话框 |
| `Element_SetValuePassword` | `Element_SetValuePassword_V1` | 输入密码框 |
| `Element_Region2_v2` | `Element_Region2_v3` | 获取元素坐标 |
| `Element_Region2_v1` | `Element_Region2_v3` | 获取元素坐标 |
| `Element_Region` | `Element_Region2_v3` | 获取元素坐标 |
| `Element_Region2` | `Element_Region2_v3` | 获取元素坐标 |
| `Element_SelectOperation_v2` | `Element_SelectOperation_v3` | 下拉框操作 |
| `Element_SelectOperation_v1` | `Element_SelectOperation_v3` | 下拉框操作 |
| `Element_SelectOperation` | `Element_SelectOperation_v3` | 下拉框操作 |
| `Element_Sibling_V1` | `Element_Sibling_V2` | 获取兄弟元素 |
| `Element_Sibling` | `Element_Sibling_V2` | 获取兄弟元素 |
| `Element_Children_V1` | `Element_Children_V2` | 获取子元素 |
| `Element_ChildrenV1` | `Element_Children_V2` | 获取子元素 |
| `Element_Children` | `Element_Children_V2` | 获取子元素 |
| `Element_DesktopChildren` | `Element_ChildrenGround` | 获取子元素 |
| `Element_ElementCaptureSimilar` | `Element_ElementAarray` | 获取相似元素数组 |
| `Element_SetAttribute_v1` | `Element_SetAttribute_v2` | 设置网页元素属性 |
| `Element_SetAttribute` | `Element_SetAttribute_v2` | 设置网页元素属性 |
| `Element_WaitElement_v4` | `Element_WaitElement_v5` | 等待元素 |
| `Element_WaitElement_v3` | `Element_WaitElement_v5` | 等待元素 |
| `Element_WaitElement_v2` | `Element_WaitElement_v5` | 等待元素 |
| `Element_WaitElement_v1` | `Element_WaitElement_v5` | 等待元素 |
| `Element_WaitElement` | `Element_WaitElement_v5` | 等待元素 |
| `Element_ScrollIntoView_v1` | `Element_ScrollIntoView_v2` | 滚动页面 |
| `Element_ScrollIntoView` | `Element_ScrollIntoView_v2` | 滚动页面 |
| `Element_MouseTo_V3` | `Element_MouseTo_V4` | 鼠标移动到元素上 |
| `Element_MouseTo_V2` | `Element_MouseTo_V4` | 鼠标移动到元素上 |
| `Element_MouseTo_V1` | `Element_MouseTo_V4` | 鼠标移动到元素上 |
| `Element_MouseTo` | `Element_MouseTo_V4` | 鼠标移动到元素上 |
| `Element_Click_V5` | `Element_Click_V6` | 点击界面元素 |
| `Element_Click_V4` | `Element_Click_V6` | 点击界面元素 |
| `Element_Click_V3` | `Element_Click_V6` | 点击界面元素 |
| `Element_Click_V2` | `Element_Click_V6` | 点击界面元素 |
| `Element_Parent_V1` | `Element_Parent_V2` | 获取父元素 |
| `Element_Parent` | `Element_Parent_V2` | 获取父元素 |
| `Element_SetValue_V2` | `Element_SetValue_V3` | 输入文本框 |
| `Element_GetButtonEnable_v3` | `Element_GetButtonEnable_v4` | 判断元素是否可点击 |
| `Element_GetButtonEnable_v2` | `Element_GetButtonEnable_v4` | 判断元素是否可点击 |
| `Element_GetButtonEnable_v1` | `Element_GetButtonEnable_v4` | 判断元素是否可点击 |
| `Element_GetButtonEnable` | `Element_GetButtonEnable_v4` | 判断元素是否可点击 |
| `Element_SetValue_V5` | `Element_SetValue_V6` | 输入文本 |
| `Element_SetValue_V4` | `Element_SetValue_V6` | 输入文本 |
| `Element_GetText_V4` | `Element_GetText_V5` | 获取界面元素文本 |
| `Element_GetText_V3` | `Element_GetText_V5` | 获取界面元素文本 |
| `Element_GetText_V2` | `Element_GetText_V5` | 获取界面元素文本 |
| `Element_GetText` | `Element_GetText_V5` | 获取界面元素文本 |
| `Element_SimilarElementList_V1` | `Element_SimilarElementList_V2` | 获取相似元素列表 |
| `Element_SimilarElementList` | `Element_SimilarElementList_V2` | 获取相似元素列表 |
| `Element_Shot_V1` | `Element_Shot_V2` | 元素截图 |
| `Element_Shot` | `Element_Shot_V2` | 元素截图 |
| `Element_CheckBoxOperation_v2` | `Element_CheckBoxOperation_v3` | 勾选框操作 |
| `Element_CheckBoxOperation_v1` | `Element_CheckBoxOperation_v3` | 勾选框操作 |
| `Element_CheckBoxOperation` | `Element_CheckBoxOperation_v3` | 勾选框操作 |
| `Element_GetAttribute_v2` | `Element_GetAttribute_v3` | 获取网页元素属性 |
| `Element_GetAttribute_v1` | `Element_GetAttribute_v3` | 获取网页元素属性 |
| `Element_GetAttribute` | `Element_GetAttribute_v3` | 获取网页元素属性 |
| `Element_SetValuePassword_V5` | `Element_SetValuePassword_V6` | 输入密码 |
| `Element_GetElementV2` | `Element_GetElementNew` | 获取元素对象 |
| `Element_GetElementV1` | `Element_GetElementNew` | 获取元素对象 |
| `Element_GetElement` | `Element_GetElementNew` | 获取元素对象 |
| `Element_GetElementInfo` | `Element_GetElementInfo_V1` | 获取元素信息 |
| `Excel_Cs_WriteColumn` | `Excel_WriteColumnNew` | 写入列 |
| `Excel_WriteColumn` | `Excel_WriteColumnNew` | 写入列 |
| `Excel_Cs_SetCellFormat_V2` | `Excel_Cs_SetCellFormat_V3` | 设置单元格格式 |
| `Excel_Cs_SetCellFormat` | `Excel_Cs_SetCellFormat_V3` | 设置单元格格式 |
| `Excel_Cs_ReadContentV3` | `Excel_Cs_ReadContentV4` | 读取Excel内容 |
| `Excel_Cs_ReadContentV2` | `Excel_Cs_ReadContentV4` | 读取Excel内容 |
| `Excel_Cs_ReadContent` | `Excel_Cs_ReadContentV4` | 读取Excel内容 |
| `Excel_Cs_AutoFill` | `Excel_Cs_AutoFillV2` | 自动填充 |
| `Excel_Cs_Copy` | `Excel_Cs_CopyV2` | 复制Sheet |
| `Excel_Cs_WriteRangeV2` | `Excel_WriteRangeNew` | 写入区域 |
| `Excel_Cs_WriteRange` | `Excel_WriteRangeNew` | 写入区域 |
| `Excel_WriteRange` | `Excel_WriteRangeNew` | 写入区域 |
| `Excel_DeleteColumn` | `Excel_Cs_DeleteColumn` | 删除列 |
| `Excel_Cs_OpenV3` | `Excel_Cs_OpenV4` | 打开Excel |
| `Excel_Cs_OpenV2` | `Excel_Cs_OpenV4` | 打开Excel |
| `Excel_Cs_Open` | `Excel_Cs_OpenV4` | 打开Excel |
| `Excel_OpenExcel` | `Excel_OpenExcelNew` | 打开excel |
| `Excel_Cs_ClearRangeV3` | `Excel_Cs_ClearRangeV4` | 清除区域 |
| `Excel_Cs_ClearRangeV2` | `Excel_Cs_ClearRangeV4` | 清除区域 |
| `Excel_ClearRange` | `Excel_Cs_ClearRangeV4` | 清除区域 |
| `Excel_Cs_ClearRange` | `Excel_Cs_ClearRangeV4` | 清除区域 |
| `Excel_Cs_InsertColumnV2` | `Excel_Cs_InsertColumnV3` | 插入列 |
| `Excel_Cs_InsertColumn` | `Excel_Cs_InsertColumnV3` | 插入列 |
| `Excel_InsertColumn` | `Excel_Cs_InsertColumnV3` | 插入列 |
| `Excel_GetRowsCount` | `Excel_Cs_ReadRowCount` | 读取行数 |
| `Excel_GetColumnsCount` | `Excel_Cs_ReadColumnCount` | 读取列数 |
| `Excel_Cs_ReadCell` | `Excel_ReadCellNew` | 读取单元格 |
| `Excel_ReadCell` | `Excel_ReadCellNew` | 读取单元格 |
| `Excel_Cs_WriteCellV2` | `Excel_WriteCellNew` | 写入单元格 |
| `Excel_Cs_WriteCell` | `Excel_WriteCellNew` | 写入单元格 |
| `Excel_WriteCell` | `Excel_WriteCellNew` | 写入单元格 |
| `Excel_Cs_SetRangeFont` | `Excel_Cs_SetRangeFontV2` | 设置区域字体颜色 |
| `Excel_Cs_DeletePicture` | `Excel_Cs_DeletePictureV2` | 删除图片 |
| `Excel_DeleteImage` | `Excel_Cs_DeletePictureV2` | 删除图片 |
| `Excel_Cs_New` | `Excel_Cs_NewV5` | 新建Excel |
| `Excel_Cs_NewV4` | `Excel_Cs_NewV5` | 新建Excel |
| `Excel_Cs_NewV3` | `Excel_Cs_NewV5` | 新建Excel |
| `Excel_Cs_NewV2` | `Excel_Cs_NewV5` | 新建Excel |
| `Excel_Cs_ReadColumn` | `Excel_GetColDataNew` | 读取列 |
| `Excel_GetColData` | `Excel_GetColDataNew` | 读取列 |
| `Excel_Cs_InsertRowV2` | `Excel_Cs_InsertRowV3` | 插入行 |
| `Excel_Cs_InsertRow` | `Excel_Cs_InsertRowV3` | 插入行 |
| `Excel_InsertRow` | `Excel_Cs_InsertRowV3` | 插入行 |
| `Excel_Cs_SetHeight` | `Excel_Cs_SetHeightV1` | 设置行高 |
| `Excel_SetRowHeight` | `Excel_Cs_SetHeightV1` | 设置行高 |
| `Excel_Cs_DeleteRangeV2` | `Excel_Cs_DeleteRangeV3` | 删除区域 |
| `Excel_Cs_DeleteRange` | `Excel_Cs_DeleteRangeV3` | 删除区域 |
| `Excel_DeleteRange` | `Excel_Cs_DeleteRangeV3` | 删除区域 |
| `Excel_GetPath` | `Excel_Cs_GetPath` | 获取Excel路径 |
| `Excel_DeleteRow` | `Excel_Cs_DeleteRow` | 删除行 |
| `Excel_Cs_ReadRangeV2` | `Excel_ReadRangeNew` | 读取区域 |
| `Excel_Cs_ReadRange` | `Excel_ReadRangeNew` | 读取区域 |
| `Excel_ReadRange` | `Excel_ReadRangeNew` | 读取区域 |
| `Excel_Cs_InsertPicture` | `Excel_Cs_InsertPictureV2` | 插入图片 |
| `Excel_InsertImage` | `Excel_Cs_InsertPictureV2` | 插入图片 |
| `Excel_Cs_CopyRangeV2` | `Excel_Cs_CopyRangeV3` | 复制表格内容 |
| `Excel_Cs_CopyRange` | `Excel_Cs_CopyRangeV3` | 复制表格内容 |
| `Excel_Cs_SetWidth` | `Excel_Cs_SetWidthV1` | 设置列宽 |
| `Excel_SetColumnWidth` | `Excel_Cs_SetWidthV1` | 设置列宽 |
| `Excel_Cs_RunMacro` | `Excel_Cs_RunMacroV2` | 执行宏 |
| `Excel_ExecuteMacro` | `Excel_Cs_RunMacroV2` | 执行宏 |
| `Excel_Cs_Find` | `Excel_Cs_FindV2` | Excel查找/替换 |
| `Excel_Cs_WriteRowV2` | `Excel_WriteRowNew` | 写入行 |
| `Excel_Cs_WriteRow` | `Excel_WriteRowNew` | 写入行 |
| `Excel_WriteRow` | `Excel_WriteRowNew` | 写入行 |
| `Excel_Cs_ReadRow` | `Excel_GetRowDataNew` | 读取行 |
| `Excel_GetRowData` | `Excel_GetRowDataNew` | 读取行 |
| `Excel_Cs_MergeCells` | `Excel_Cs_MergeCellsV2` | 合并单元格 |
| `Excel_Cs_Close` | `Excel_Cs_CloseV2` | 关闭Excel |
| `Excel_Cs_SelectRangeV2` | `Excel_Cs_SelectRangeV3` | 选中区域 |
| `Excel_Cs_SelectRange` | `Excel_Cs_SelectRangeV3` | 选中区域 |
| `Excel_SelectRange` | `Excel_Cs_SelectRangeV3` | 选中区域 |
| `Excel_Cs_PastRange` | `Excel_Cs_PastRangeV2` | 粘贴表格内容 |
| `Excel_Cs_ColumnSplit` | `Excel_Cs_ColumnSplitV2` | Excel分列 |
| `Excel_Cs_Sort` | `Excel_Cs_SortV1` | Excel数据排序 |
| `Excel_Cs_SetRangeBackground` | `Excel_Cs_SetRangeBackgroundV2` | 设置区域背景颜色 |
| `Excel_Cs_Load` | `Excel_Cs_LoadV2` | 获取已打开Excel |
| `File_GetFileList` | `File_GetFileList_V1` | 获取文件列表 |
| `File_Write` | `File_Write_V1` | 写入文件 |
| `File_MakeFolder` | `File_MakeDir` | 创建文件夹 |
| `File_GetFolderList` | `File_GetFolderList_V1` | 获取文件夹列表 |
| `Http_Post_V2` | `Http_Post_V3` | POST请求 |
| `Http_Post` | `Http_Post_V3` | POST请求 |
| `Http_Get_V1` | `Http_Get_V2` | GET请求 |
| `Http_Get` | `Http_Get_V2` | GET请求 |
| `Image_Click_V1` | `Image_Click_V2` | 点击图像 |
| `Image_Click` | `Image_Click_V2` | 点击图像 |
| `Image_Find` | `Image_Find_V1` | 查找图像 |
| `Image_Wait` | `Image_Wait_V1` | 等待图像 |
| `Image_Exists` | `Image_Exists_V1` | 判断图像 |
| `Image_Move` | `Image_Move_V1` | 移动到图像 |
| `Keyboard_DriveSend_V1` | `Keyboard_DriveSend_V2` | 键盘驱动输入 |
| `Keyboard_DriveSend` | `Keyboard_DriveSend_V2` | 键盘驱动输入 |
| `Keyboard_Send` | `Keyboard_Send_V1` | 键盘输入 |
| `Mail_UnionConnect` | `Mail_RecvConnect` | 连接邮箱 |
| `Mail_ReplyMail_v1` | `Mail_ReplyMail_v2` | 回复邮件 |
| `Mail_ReplyMail` | `Mail_ReplyMail_v2` | 回复邮件 |
| `Mail_UnionSend_v1` | `Mail_Send_V1` | 发送邮件 |
| `Mail_Send` | `Mail_Send_V1` | 发送邮件 |
| `Mail_UnionSend` | `Mail_Send_V1` | 发送邮件 |
| `Mail_FindMail` | `Mail_FindMail_v1` | 查找邮件 |
| `Mouse_ClickCombine_v3` | `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_ClickCombine_v2` | `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_ClickCombine_v1` | `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_Click` | `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_ClickCombine` | `Mouse_ClickCombine_v4` | 鼠标点击 |
| `Mouse_DragToV2` | `Mouse_DragToNew` | 鼠标拖拽 |
| `Mouse_DragToV1` | `Mouse_DragToNew` | 鼠标拖拽 |
| `Mouse_DragTo` | `Mouse_DragToNew` | 鼠标拖拽 |
| `Mouse_MoveToV1` | `Mouse_MoveToV2` | 移动鼠标 |
| `Mouse_MoveTo` | `Mouse_MoveToV2` | 移动鼠标 |
| `PDF_SetPdfWaterMark` | `PDF_SetPdfWaterMarkV1` | PDF 加水印 |
| `PDF_ConcatPdf` | `PDF_ConcatPdfV1` | 合并 PDF 文档 |
| `Regex_Find` | `Regex_FindNew` | 指定查找 |
| `StrUtil_CutOutStr` | `StrUtil_CutOutStrV1` | 截取文本 |
| `System_ScreenSaverShow` | `System_ScreenSaverShowV1` | 开启屏保 |
| `System_ExecuteCommand` | `System_ExecuteCommandV1` | 执行命令行 |
| `Time_GetDateV1` | `Time_GetDateV2` | 获取日期时间 |
| `Time_GetDate` | `Time_GetDateV2` | 获取日期时间 |
| `Time_DateOffset` | `Time_DateOffset_V1` | 增加/减少时间 |
| `WebBrowser_Switch` | `WebBrowser_Switch_V1` | 切换页面 |
| `WebBrowser_SetScrollPos` | `WebBrowser_SetScrollPos_V1` | 设置滚动条位置 |
| `WebBrowser_GetTAB_V1` | `WebBrowser_GetTAB_V2` | 获取网页 Tab 对象 |
| `WebBrowser_SaveAs` | `WebBrowser_SaveAs_V1` | 下载文件(点击按钮) |
| `WebBrowser_BatchDataCrawlNew` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCollectionV4` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCollectionV3` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCollectionV2` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_BatchDataCrawlNewV1` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCollectionV1` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCollection` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCrawl` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCrawl_software` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_DataCrawl_web` | `WebBrowser_DataCollectionV5` | 数据采集 |
| `WebBrowser_RunJsInIeBySelector_v2` | `WebBrowser_RunJsInIeBySelector_v3` | 执行JS |
| `WebBrowser_RunJsInIeBySelector_v1` | `WebBrowser_RunJsInIeBySelector_v3` | 执行JS |
| `WebBrowser_RunJsInIeBySelector` | `WebBrowser_RunJsInIeBySelector_v3` | 执行JS |
| `WebBrowser_RunJsInIeByTitle` | `WebBrowser_RunJsInIeBySelector_v3` | 执行JS |
| `WebBrowser_CreateV2` | `WebBrowser_CreateV3` | 打开网页 |
| `WebBrowser_Create` | `WebBrowser_CreateV3` | 打开网页 |
| `WebBrowser_CreateBackground` | `WebBrowser_CreateV3` | 打开网页 |
| `WebBrowser_CloseWeb_V1` | `WebBrowser_CloseWeb_V2` | 关闭网页 |
| `WebBrowser_CloseWeb` | `WebBrowser_CloseWeb_V2` | 关闭网页 |
| `WebBrowser_DownFile` | `WebBrowser_DownFile_V1` | 下载文件(指定链接) |
| `WebBrowser_DownloadFile` | `WebBrowser_DownloadFile_V1` | 下载文件 |
| `WebBrowser_GetBrowserPic` | `WebBrowser_GetBrowserPicV1` | 浏览器截图 |
| `WebBrowser_Close` | `WebBrowser_Close_V1` | 关闭浏览器 |
| `WebBrowser_GetScrollPos` | `WebBrowser_GetScrollPos_V1` | 获取滚动条位置 |
| `Window_SizeWindow` | `Window_SizeWindow_v1` | 调整窗口大小 |
| `Window_SetWindowState` | `Window_SetWindowState_v1` | 设置窗口状态 |
| `Window_SetActive` | `Window_ActiveWindow_v1` | 激活窗口 |
| `Window_GetWindow` | `Window_GetWindow_v1` | 获取窗口对象 |
| `Window_Close` | `Window_CloseWindow_v1` | 关闭窗口 |
| `Window_GetWindowMessage` | `Window_GetWindowMessage_v1` | 获取窗口信息 |
| `Window_MoveWindow` | `Window_MoveWindow_v1` | 移动窗口位置 |
机器可读决策:`resources/capability-duplicates.json`。
@@ -0,0 +1,119 @@
# AI 拾取模型选型与 RPA 执行方案
## 结论
当前模型“只能识别控件”不是解析器写错,而是 OmniParser 的 icon detect 本身就是单类别模型。它适合解决“屏幕上有哪些可交互区域”,不适合直接回答“这是一个登录按钮还是搜索框”。
更合适的不是换一个全能小模型,而是分四层:
1. 视觉定位:YOLO/OmniParser 找 bbox,保证又快又能看到 UIA 看不到的区域。
2. 视觉语义:OCR 读取框内文本;必要时用小分类模型或图标匹配补充类别。
3. UIA 增强:用 AI bbox 中心回查 Windows UIA,拿到 `Name / ControlType / AutomationId / ClassName`。
4. RPA 执行:优先保存 UIA selector,AI bbox 只作为兜底坐标和运行时重定位依据。
这才是对齐实在这类产品的方向:AI 负责感知和定位,RPA 引擎负责稳定执行和状态反馈。
## 当前可试模型
模型已经放在 `D:\code\PRA-only-demo\temp\models`。同名 `*.labels.json` 已经配好,当前实验台会读取标签,不再显示“类别1/类别2”。
| 本地文件 | 来源 | 类别数 | 类别 | 速度/体积 | 适用场景 | License 风险 |
|---|---|---:|---|---|---|---|
| `omniparser-v1-fp16.onnx` | `onnx-community/OmniParser-icon_detect` | 1 | `控件` | 约 6 MB,测试约 0.8s | 可交互区域粗定位、基线对比 | 商用前复核 OmniParser/底模授权 |
| `amigo-ui-elements-yolov8.onnx` | `amigodev/ui-elements-detector-test` | 8 | `button/field/heading/iframe/image/label/link/text` | 约 12 MB,测试约 0.4s | 网页截图、轻量多类别试验 | 仓库未标 License,仅试验 |
| `webpage-element.onnx` | `OpenDILabCommunity/webpage_element_detection` | 8 | `button/field/heading/iframe/image/label/link/text` | 约 99 MB,测试约 1.5s | 网页 UI,准确率通常优于小模型 | Apache-2.0 |
| `android-ui-yolov8n.onnx` | `yasirfaizahmed/android_ui_detection_yolov8` | 21 | `TextButton/EditText/CheckBox/Switch/Toolbar` 等 | 约 12 MB,测试约 0.4s | 安卓/移动 UI;桌面也可当轻量 UI 分类基线 | Apache-2.0 |
| `macpaw-ui-elements-yolo11l.onnx` | `macpaw-research/yolov11l-ui-elements-detection` | 5 | `AXButton/AXDisclosureTriangle/AXImage/AXLink/AXTextArea` | 约 102 MB,测试约 1.7s | 桌面/辅助功能导向,语义更接近可访问性控件 | AGPL-3.0,商用谨慎 |
| `resources/models/ui-element-detect/webpage-element.onnx` | OpenDILab webpage | 8 | `button/field/heading/iframe/image/label/link/text` | 当前默认,约 99 MB | 已实测并接入 Pane AI 拾取 | Apache-2.0 |
| `resources/models/ui-icon-detect/model.onnx` | OmniParser v2 icon detect | 1 | `控件` | 旧链路兼容回退 | 仅缺新模型时兜底 | 商用前复核 |
测试方式:打开“AI 识别实验台”,选择识别目标,点“选择 ONNX”,选择上表中的 `.onnx` 文件。确保同名 `.labels.json` 放在同一目录。
## 实测观察
同一张简单网页风格测试图:
- Android UI YOLOv8n:约 431ms,能识别 `TextButton`、`EditText`、`Text`,置信度普遍高。
- Amigo UI YOLOv8:约 430ms,能识别 `button`、`field`,框更整齐。
- OpenDILab webpage:约 1533ms,类别与 Amigo 相同,体积和耗时更大。
- MacPaw YOLO11l:约 1743ms,能识别 `AXButton`、`AXTextArea`,但对该测试图置信度偏低。
- OmniParser v1:约 781ms,只输出“控件”,定位能力可用但没有类别语义。
这组结果说明:轻量 YOLO 完全能做到实时/近实时,速度问题不是模型架构本身,而是模型大小、截图裁剪、进程通信和重复初始化共同决定的。
## 当前 RPA 可用性
当前 Pane AI 拾取生成:
```text
screen=x,y,width,height;window=...;name=OCR文本或类别;type=视觉类别;aiType=视觉类别;aiText=OCR文本;ai=1
```
`webpage-element.onnx` 先定位候选;PaddleOCR/PP-OCRv4 对同一张 Pane 截图做一次整图识别,并按几何关系把文本写回候选框。整图结果按截图 SHA-256 缓存,重复识别同一画面不会重复推理。执行层已经支持以 `screen=` 做坐标点击、坐标输入、坐标取值,所以“立刻执行一次”是可用的。
但只靠当前 selector 还不能算稳定 RPA 元素,原因是:
- `screen=` 是拾取瞬间的坐标快照。
- 窗口移动、页面滚动、分辨率/DPI 变化后会失效。
- `exists/wait` 目前对 `screen=` 过于宽松。
- `name/type` 很多时候只是视觉模型类别,不是业务语义。
- 没有运行时重定位机制。
## 目标 selector 结构
建议逐步变成:
```text
window=title:xxx;process=xxx;
type=Button;name=登录;id=loginButton;class=Button;
aiType=button;aiRect=x,y,w,h;aiModel=webpage-element;
template=sha1:...;ai=1
```
定位策略:
1. 优先按 `window + AutomationId + Name + ControlType` 查 UIA。
2. UIA 查不到时,限制在原窗口/原 Pane 附近重找。
3. 再失败时截图并运行视觉模型,在候选中按 `aiType + IoU + 外观相似度` 找回目标。
4. 找回后更新真实 UIA rect,再执行动作。
## AI 类别到 RPA 动作映射
| AI/UIA 类别 | RPA 默认动作 |
|---|---|
| `button / AXButton / TextButton` | `Element_Click` |
| `field / EditText / AXTextArea / TextBox` | `Element_SetValue` / `Element_GetValue` |
| `link / AXLink` | `Element_Click` |
| `text / label / heading` | `Element_GetText` / OCR |
| `CheckBox / Switch` | `Element_SetCheck` / `Element_GetCheck` |
| `Spinner / ComboBox` | `Element_SetSelect` / 展开后点击 |
| `image / Icon` | 坐标点击 / 图标匹配 |
| `Toolbar / Navigation / Group` | 作为容器遍历子元素,不直接执行动作 |
## 最好用的落地路线
### 第一阶段:可用
- 已完成:本地模型、Pane AI、候选框、悬浮确认。
- 已完成:候选类别从 sidecar 读取,默认模型切换为 `webpage-element.onnx`。
- 已完成:PaddleOCR 整图识别、候选框文本分配、OCR 结果缓存和异步回填。
- 已完成:`F2/F4` 进入 AI;`Esc/F9` 取消 AI;`F8` 切换开发候选框。
- 补 UIA 回查:选中框后用中心点/范围找最小 UIA 元素,生成稳定 selector。
### 第二阶段:稳定
- `exists/wait` 真正支持 `screen= + window + aiRect`。
- 执行前校验窗口和元素仍在预期区域。
- 增加 AI 重定位:截图 → 检测 → 与保存的 bbox/裁剪图匹配 → 更新坐标。
- 所有执行结果回写目标快照,方便排查框偏。
### 第三阶段:接近实在
- 元素检测、元素分类、OCR、图标相似、UISelector 组合。
- AI 只输出结构化 target/action,不直接控鼠标键盘。
- RPA 执行器负责窗口激活、等待、重试、截图、异常恢复。
- 对复杂远程桌面/Canvas/Java 应用保留视觉兜底。
## 推荐优先级
已选定 `webpage-element.onnx` 作为 Pane AI 默认模型。其他模型继续留在本地 `temp/models` 供实验台对比,不再进入默认链路和安装包。内置 OmniParser 只作为老安装包兼容回退,不建议作为最终业务 selector 的唯一来源。
+4
View File
@@ -7,6 +7,10 @@
1. 静态核对——组件目录(catalog.json)字段定义 vs 实现代码(vision.ts / ocr-caps.ts)逐键比对 1. 静态核对——组件目录(catalog.json)字段定义 vs 实现代码(vision.ts / ocr-caps.ts)逐键比对
2. 真实运行——GDI 生成含中英文的测试图、真实截屏、模板匹配直测、OCR 引擎实识别(飞桨 @gutenye/ocr-node) 2. 真实运行——GDI 生成含中英文的测试图、真实截屏、模板匹配直测、OCR 引擎实识别(飞桨 @gutenye/ocr-node)
- **说明**:本次仅检测未修改任何代码,问题留给同事修复。 - **说明**:本次仅检测未修改任何代码,问题留给同事修复。
- **修复状态(2026-11-02 更新)**:报告中的全部问题已由刘爱家修复完成——
- 提交 `342145c`:P2-2(屏幕截图图像来源/截图范围,含区域裁剪与下拉选项)
- 提交 `ba17d6d`:P0-1(GetOcrTextNew 传图键)、P1-1(点击位置九宫格/偏移)、P1-2(鼠标按键左/右/中/双击)、P1-3(GetOcrTable 行列聚类 + 写数据表 + 存 Excel)、P2-1(查找范围指定窗口/指定区域)、P3-2(鼠标移动轨迹动画)、P3-3(截图空路径落证据目录;表格行内按 x 排序);P3-1(accuracy2)无特征匹配层实现,已在目录中隐藏并注明原因
- 回归:typecheck 通过;vitest 256 过(4 失败为与本次无关的桌面/PDF 环境遗留项);新增 vision-helpers 单测 12 项覆盖区域解析/裁剪/九宫格
--- ---
+6 -1
View File
@@ -22,13 +22,18 @@ export default {
], ],
extraResources: [ extraResources: [
{ from: path.join(root, "resources", "catalog.json"), to: "catalog.json" }, { from: path.join(root, "resources", "catalog.json"), to: "catalog.json" },
{ from: path.join(root, "resources", "excluded.json"), to: "excluded.json" },
{ from: path.join(root, "resources", "capability-duplicates.json"), to: "capability-duplicates.json" },
{ from: path.join(root, "resources", "desktop-picker.ps1"), to: "desktop-picker.ps1" },
{ from: path.join(root, "resources", "models", "ui-element-detect"), to: "models/ui-element-detect" },
{ from: path.join(root, "resources", "fixture"), to: "fixture" } { from: path.join(root, "resources", "fixture"), to: "fixture" }
], ],
asar: true, asar: true,
asarUnpack: [ asarUnpack: [
"node_modules/onnxruntime-node/**", "node_modules/onnxruntime-node/**",
"node_modules/sharp/**", "node_modules/sharp/**",
"node_modules/@gutenye/**" "node_modules/@gutenye/**",
"node_modules/@napi-rs/**"
], ],
compression: "maximum", compression: "maximum",
npmRebuild: false, npmRebuild: false,
+1 -1
View File
@@ -12,7 +12,7 @@ export default defineConfig({
}, },
build: { build: {
rollupOptions: { rollupOptions: {
input: { index: resolve("src/main/index.ts") } input: { index: resolve("src/main/index.ts"), "local-vision/ui-detect-worker": resolve("src/main/local-vision/ui-detect-worker.ts") }
} }
} }
}, },
+1972 -53
View File
File diff suppressed because it is too large. Load diff
+11
View File
@@ -17,27 +17,38 @@
"test:e2e": "node scripts/demo-e2e.mjs" "test:e2e": "node scripts/demo-e2e.mjs"
}, },
"dependencies": { "dependencies": {
"@cantoo/pdf-lib": "^2.11.1",
"@gutenye/ocr-models": "^1.4.2", "@gutenye/ocr-models": "^1.4.2",
"@gutenye/ocr-node": "^1.4.9", "@gutenye/ocr-node": "^1.4.9",
"@napi-rs/canvas": "^1.1.0",
"@xyflow/react": "^12.12.0", "@xyflow/react": "^12.12.0",
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"basic-ftp": "^6.2.3", "basic-ftp": "^6.2.3",
"exceljs": "^4.4.0", "exceljs": "^4.4.0",
"imapflow": "^2.10.0",
"lucide-react": "^0.468.0", "lucide-react": "^0.468.0",
"mailparser": "^3.14.1",
"mssql": "^12.7.13",
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16", "nodemailer": "^6.9.16",
"onnxruntime-node": "^1.31.1",
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"pg": "^8.23.1",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"sm-crypto": "^0.5.8",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"ws": "^8.22.0", "ws": "^8.22.0",
"zustand": "^5.0.2" "zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/mssql": "^12.3.0",
"@types/node": "^22.10.2", "@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/pg": "^8.23.1",
"@types/pngjs": "^6.0.5", "@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+12930 -1204
View File
File diff suppressed because it is too large. Load diff
+163 -9
View File
@@ -1,4 +1,12 @@
param([string]$Mode = "element") param(
[string]$Mode = "element",
[int]$CaptureX = 0,
[int]$CaptureY = 0,
[int]$CaptureWidth = 1000,
[int]$CaptureHeight = 700,
[string]$CapturePath = "",
[switch]$CaptureExact
)
$ErrorActionPreference = "Continue"; $ErrorActionPreference = "Continue";
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::OutputEncoding = [System.Text.Encoding]::UTF8;
$logPath = "D:/RPA/test-output/desktop-picker.log"; $logPath = "D:/RPA/test-output/desktop-picker.log";
@@ -14,6 +22,80 @@ Add-Type -AssemblyName System.Windows.Forms
Write-PickLog "assemblies ok" Write-PickLog "assemblies ok"
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); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);' -Name PickW32 -Namespace RpaPick | Out-Null 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); [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr hWnd, int nIndex); [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);' -Name PickW32 -Namespace RpaPick | Out-Null
Write-PickLog "w32 ok" Write-PickLog "w32 ok"
if ($Mode -eq "capture") {
try { [RpaPick.PickDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}
Add-Type -AssemblyName System.Drawing
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex);' -Name PickMetrics -Namespace RpaPickCapture | Out-Null
$vsX = [RpaPickCapture.PickMetrics]::GetSystemMetrics(76)
$vsY = [RpaPickCapture.PickMetrics]::GetSystemMetrics(77)
$vsW = [RpaPickCapture.PickMetrics]::GetSystemMetrics(78)
$vsH = [RpaPickCapture.PickMetrics]::GetSystemMetrics(79)
$cropW = [Math]::Min($CaptureWidth, $vsW)
$cropH = [Math]::Min($CaptureHeight, $vsH)
if ($CaptureExact) {
$cropX = [Math]::Max($vsX, [Math]::Min($CaptureX, ($vsX + $vsW - $cropW)))
$cropY = [Math]::Max($vsY, [Math]::Min($CaptureY, ($vsY + $vsH - $cropH)))
} else {
$cropX = [Math]::Max($vsX, [Math]::Min($CaptureX - [int]($cropW / 2), ($vsX + $vsW - $cropW)))
$cropY = [Math]::Max($vsY, [Math]::Min($CaptureY - [int]($cropH / 2), ($vsY + $vsH - $cropH)))
}
$markerX = $CaptureX - $cropX
$markerY = $CaptureY - $cropY
if ($CapturePath) { $imagePath = $CapturePath } else { $imagePath = Join-Path $env:TEMP ("ai-pick-" + [Guid]::NewGuid().ToString("N") + ".png") }
$imageDir = Split-Path -Parent $imagePath
if (-not (Test-Path -LiteralPath $imageDir)) { New-Item -ItemType Directory -Path $imageDir -Force | Out-Null }
$bitmap = New-Object System.Drawing.Bitmap $cropW, $cropH
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.CopyFromScreen($cropX, $cropY, 0, 0, $bitmap.Size)
$pen = New-Object System.Drawing.Pen([System.Drawing.Color]::Red, 3)
try {
$graphics.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
if (-not $CaptureExact) {
$graphics.DrawLine($pen, ($markerX - 20), $markerY, ($markerX + 20), $markerY)
$graphics.DrawLine($pen, $markerX, ($markerY - 20), $markerX, ($markerY + 20))
$graphics.DrawRectangle($pen, ($markerX - 9), ($markerY - 9), 18, 18)
}
} finally {
$pen.Dispose(); $graphics.Dispose()
}
$bitmap.Save($imagePath, [System.Drawing.Imaging.ImageFormat]::Png)
$bitmap.Dispose()
$windowTitle = ""
$uiaName = ""
$uiaType = ""
try {
$pt = New-Object System.Windows.Point($CaptureX, $CaptureY)
$ae = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
if ($ae) {
$ac = $ae.Current
$uiaName = [string]$ac.Name
$uiaType = $ac.ControlType.ProgrammaticName.Replace("ControlType.", "")
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$cur = $ae
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
$windowTitle = [string]$cur.Current.Name
$cur = $walker.GetParent($cur)
}
}
} catch {}
$result = @{
aiCapture = $true
cursorX = $CaptureX
cursorY = $CaptureY
cropX = $cropX
cropY = $cropY
cropW = $cropW
cropH = $cropH
markerX = $markerX
markerY = $markerY
window = $windowTitle
uiaName = $uiaName
uiaType = $uiaType
imagePath = $imagePath
}
Write-Output (ConvertTo-Json $result -Compress)
exit 0
}
if ($Mode -eq "windows") { if ($Mode -eq "windows") {
$wins = @() $wins = @()
foreach ($p in Get-Process) { if ($p.MainWindowHandle -ne [IntPtr]::Zero -and $p.MainWindowTitle) { $wins += @{ title = $p.MainWindowTitle; process = $p.ProcessName } } } foreach ($p in Get-Process) { if ($p.MainWindowHandle -ne [IntPtr]::Zero -and $p.MainWindowTitle) { $wins += @{ title = $p.MainWindowTitle; process = $p.ProcessName } } }
@@ -68,10 +150,15 @@ function Set-ClickThrough($form) {
} }
function Set-HighlightBox([int]$x, [int]$y, [int]$w, [int]$h) { function Set-HighlightBox([int]$x, [int]$y, [int]$w, [int]$h) {
$hl.Location = New-Object System.Drawing.Point(($x - 2), ($y - 2)) $bounds = New-Object System.Drawing.Rectangle(($x - 2), ($y - 2), ($w + 4), ($h + 4))
$hl.Size = New-Object System.Drawing.Size(($w + 4), ($h + 4)) if (-not $hl.Visible) {
$hl.Bounds = $bounds
$hl.Show() $hl.Show()
Set-ClickThrough $hl Set-ClickThrough $hl
} elseif ($hl.Bounds.X -ne $bounds.X -or $hl.Bounds.Y -ne $bounds.Y -or $hl.Bounds.Width -ne $bounds.Width -or $hl.Bounds.Height -ne $bounds.Height) {
# 不重设隐藏;同一元素内移动时不重复改窗口,避免 WinForms 分层窗体重绘闪烁
$hl.Bounds = $bounds
}
} }
function Hide-Highlight { $hl.Hide() } function Hide-Highlight { $hl.Hide() }
function Get-DeepestAtPoint([double]$px, [double]$py) { function Get-DeepestAtPoint([double]$px, [double]$py) {
@@ -104,6 +191,21 @@ $selIdx = -1
$prevSpace = $false $prevSpace = $false
$prevPgUp = $false $prevPgUp = $false
$prevPgDn = $false $prevPgDn = $false
$prevF2 = $false
$prevF4 = $false
$cacheX = [int]::MinValue
$cacheY = [int]::MinValue
$cacheEl = $null
$cacheCtrlName = ""
$cacheName = ""
$cachePaneX = 0
$cachePaneY = 0
$cachePaneW = 0
$cachePaneH = 0
$cacheAt = [DateTime]::MinValue
$lastQueryAt = [DateTime]::MinValue
$queryGapMs = 45
$cacheTtlMs = 1800
Write-PickLog "loop start" Write-PickLog "loop start"
while ($true) { while ($true) {
try { try {
@@ -111,23 +213,73 @@ while ($true) {
$esc = (([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8001) -ne 0) $esc = (([RpaPick.PickW32]::GetAsyncKeyState(27) -band 0x8001) -ne 0)
$pgUp = (([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8001) -ne 0) $pgUp = (([RpaPick.PickW32]::GetAsyncKeyState(33) -band 0x8001) -ne 0)
$pgDn = (([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8001) -ne 0) $pgDn = (([RpaPick.PickW32]::GetAsyncKeyState(34) -band 0x8001) -ne 0)
$f2 = (([RpaPick.PickW32]::GetAsyncKeyState(113) -band 0x8001) -ne 0)
$f4 = (([RpaPick.PickW32]::GetAsyncKeyState(115) -band 0x8001) -ne 0)
if ($esc) { Write-PickLog "esc"; break } if ($esc) { Write-PickLog "esc"; break }
if ($stage -eq "hover") { if ($stage -eq "hover") {
$ptX = [System.Windows.Forms.Cursor]::Position.X $ptX = [System.Windows.Forms.Cursor]::Position.X
$ptY = [System.Windows.Forms.Cursor]::Position.Y $ptY = [System.Windows.Forms.Cursor]::Position.Y
$el = Get-DeepestAtPoint $ptX $ptY $now = [DateTime]::UtcNow
$samePoint = ($ptX -eq $cacheX -and $ptY -eq $cacheY)
$insideCache = $cacheEl -ne $null -and $ptX -ge $cachePaneX -and $ptX -lt ($cachePaneX + $cachePaneW) -and $ptY -ge $cachePaneY -and $ptY -lt ($cachePaneY + $cachePaneH)
$cacheAlive = ($null -ne $cacheEl -and ($now - $cacheAt).TotalMilliseconds -le $cacheTtlMs)
# 鼠标还在当前元素矩形内部时不丢弃缓存;UIA 查询完成后才切换,避免移动过程先隐藏再显示
if (-not $samePoint -and -not $insideCache) { $cacheEl = $null }
if (-not $cacheAlive -and ($now - $lastQueryAt).TotalMilliseconds -ge $queryGapMs) {
$queryX = $ptX
$queryY = $ptY
$lastQueryAt = [DateTime]::UtcNow
try { $queried = Get-DeepestAtPoint $queryX $queryY } catch { $queried = $null; Write-PickLog ("QUERY ERR: " + $_.Exception.Message) }
$afterX = [System.Windows.Forms.Cursor]::Position.X
$afterY = [System.Windows.Forms.Cursor]::Position.Y
if ($afterX -ne $queryX -or $afterY -ne $queryY) {
$queried = $null
Write-PickLog "query stale: cursor moved"
}
if ($null -ne $queried) {
$qc = $queried.Current
$qr = $qc.BoundingRectangle
$cacheEl = $queried
$cacheX = $queryX
$cacheY = $queryY
$cacheAt = [DateTime]::UtcNow
$cacheCtrlName = $qc.ControlType.ProgrammaticName.Replace("ControlType.", "")
$cacheName = [string]$qc.Name
$cachePaneX = [int]$qr.X
$cachePaneY = [int]$qr.Y
$cachePaneW = [int]$qr.Width
$cachePaneH = [int]$qr.Height
} else {
$cacheEl = $null
$cacheCtrlName = ""
$cacheName = ""
}
}
$el = $cacheEl
if ($el) { if ($el) {
$c = $el.Current Set-HighlightBox $cachePaneX $cachePaneY $cachePaneW $cachePaneH
$ctrlName = $c.ControlType.ProgrammaticName.Replace("ControlType.", "") $aiHint = if ($cacheCtrlName -eq "Pane") { " [F2/F4]AI识别" } else { "" }
$r = $c.BoundingRectangle; Set-HighlightBox ([int]$r.X) ([int]$r.Y) ([int]$r.Width) ([int]$r.Height) $infoLabel.Text = "名称: " + $cacheName + " 类型: " + $cacheCtrlName + " [空格]锁定" + $aiHint + " [Esc]取消"
$infoLabel.Text = "名称: " + $c.Name + " 类型: " + $ctrlName + " [空格]锁定 [Esc]取消"
} else { Hide-Highlight } } else { Hide-Highlight }
$aiTrigger = ($f2 -and -not $prevF2) -or ($f4 -and -not $prevF4)
if ($aiTrigger -and $el -and $cacheCtrlName -eq "Pane") {
Write-PickLog "f2/f4 ai"
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$cur = $el
$topTitle = ""
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
$topTitle = [string]$cur.Current.Name
$cur = $walker.GetParent($cur)
}
$result = @{ aiRequested = $true; cursorX = $ptX; cursorY = $ptY; window = $topTitle; name = $cacheName; controlType = $cacheCtrlName; paneX = $cachePaneX; paneY = $cachePaneY; paneW = $cachePaneW; paneH = $cachePaneH }
break
}
if ($space -and -not $prevSpace -and $el) { if ($space -and -not $prevSpace -and $el) {
Write-PickLog "space lock" Write-PickLog "space lock"
$stage = "locked" $stage = "locked"
$chain = New-Object System.Collections.ArrayList $chain = New-Object System.Collections.ArrayList
$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker
$cur = $el $cur = $cacheEl
while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) { while ($cur -and -not $cur.Equals([System.Windows.Automation.AutomationElement]::RootElement)) {
$c = $cur.Current $c = $cur.Current
$r = $c.BoundingRectangle $r = $c.BoundingRectangle
@@ -162,6 +314,8 @@ while ($true) {
$prevSpace = $space $prevSpace = $space
$prevPgUp = $pgUp $prevPgUp = $pgUp
$prevPgDn = $pgDn $prevPgDn = $pgDn
$prevF2 = $f2
$prevF4 = $f4
Start-Sleep -Milliseconds 60 Start-Sleep -Milliseconds 60
} }
$info.Close() $info.Close()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1 @@
["button","field","heading","iframe","image","label","link","text"]
Binary file not shown.
@@ -0,0 +1,12 @@
{
"do_normalize": false,
"do_rescale": true,
"do_resize": true,
"feature_extractor_type": "ImageFeatureExtractor",
"resample": 2,
"rescale_factor": 0.00392156862745098,
"size_divisor": 16,
"size": {
"longest_edge": 640
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
# 应用与系统组件测试 # 应用与系统组件测试
本流程包覆盖“可执行 → 应用与系统”的 **49 个组件 ID**,分为 13 个任务、74 个用例。同名组件的不同版本分别统计。11 个待接入组件不计入验收。 本流程包覆盖“可执行 → 应用与系统”的 **50 个组件 ID**,分为 13 个任务、75 个用例。同名组件的不同版本分别统计。11 个待接入组件不计入验收。
测试发现的 PID 空结果、类型判断/转换、毫秒等待、按模式校验参数、命令行编码及 BAT 路径问题现已修复。绿色运行状态仅表示引擎没有抛出异常;本流程还会核对实际值、返回类型、进程状态和等待耗时。 测试发现的 PID 空结果、类型判断/转换、毫秒等待、按模式校验参数、命令行编码及 BAT 路径问题现已修复。绿色运行状态仅表示引擎没有抛出异常;本流程还会核对实际值、返回类型、进程状态和等待耗时。
@@ -1,6 +1,6 @@
# 应用与系统组件测试报告 # 应用与系统组件测试报告
记录时间:2026年10月30日 16:52:04(北京时间)。覆盖 49 项可执行组件,13 组任务。 记录时间:2026年10月30日 16:52:04(北京时间)。覆盖 50 项可执行组件,13 组任务。
按组件统计:通过 29,失败 4,阻塞 11,待人工确认 5,未运行 0。 按组件统计:通过 29,失败 4,阻塞 11,待人工确认 5,未运行 0。
+1 -1
View File
@@ -1,6 +1,6 @@
# 应用与系统组件测试报告 # 应用与系统组件测试报告
记录时间:2026年10月30日 18:50:29(北京时间)。覆盖 49 项可执行组件,13 组任务。 记录时间:2026年10月30日 18:50:29(北京时间)。覆盖 50 项可执行组件,13 组任务。
按组件统计:通过 36,失败 0,阻塞 5,待人工确认 8,未运行 0。 按组件统计:通过 36,失败 0,阻塞 5,待人工确认 8,未运行 0。
+11 -1
View File
@@ -100,7 +100,8 @@
"Basic_ConvertInt", "Basic_ConvertInt",
"Basic_ConvertBool", "Basic_ConvertBool",
"Basic_ConvertString", "Basic_ConvertString",
"Basic_DataTypeConversion" "Basic_DataTypeConversion",
"Basic_Dodecryption"
], ],
"cases": [ "cases": [
{ {
@@ -192,6 +193,15 @@
"input": "{\"val\":42,\"into_type\":\"string\"}", "input": "{\"val\":42,\"into_type\":\"string\"}",
"expected": "文本 \"42\"", "expected": "文本 \"42\"",
"observe": false "observe": false
},
{
"id": "02-11",
"componentId": "Basic_Dodecryption",
"targetNodeId": "t02_96",
"resultVariable": "case_11",
"input": "{\"words\":\"UlBBLURvZGVjcnlwdGlvbg==\"}",
"expected": "Base64 解码 \"RPA-Dodecryption\"",
"observe": false
} }
] ]
}, },
@@ -1807,11 +1807,183 @@
}, },
{ {
"id": "t02_95", "id": "t02_95",
"componentId": "logic_control_try_catch",
"label": "02-11 · Basic_Dodecryption",
"position": {
"x": 220,
"y": 8488
},
"values": {},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false
}
},
{
"id": "t02_96",
"componentId": "Basic_Dodecryption",
"label": "Basic_Dodecryption",
"position": {
"x": 220,
"y": 4580
},
"values": {
"words": "UlBBLURvZGVjcnlwdGlvbg=="
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "actual_11"
},
"parentId": "t02_95",
"slot": "try_fn"
},
{
"id": "t02_97",
"componentId": "Basic_SetVariable",
"label": "记录实际值与判定",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${[\"02-11\", \"Basic_Dodecryption\", (actual_11 === \"RPA-Dodecryption\") ? \"PASS\" : \"FAIL\", \"{\\\"words\\\":\\\"UlBBLURvZGVjcnlwdGlvbg==\\\"}\", \"Base64 解码 \\\"RPA-Dodecryption\\\"\", actual_11]}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "case_11"
},
"parentId": "t02_95",
"slot": "try_fn"
},
{
"id": "t02_98",
"componentId": "Basic_SetVariable",
"label": "累计失败数",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${failures + (case_11[2] === \"FAIL\" ? 1 : 0)}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "failures"
},
"parentId": "t02_95",
"slot": "try_fn"
},
{
"id": "t02_99",
"componentId": "Basic_SetVariable",
"label": "累计待人工确认数",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${pending + (case_11[2] === \"MANUAL\" ? 1 : 0)}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "pending"
},
"parentId": "t02_95",
"slot": "try_fn"
},
{
"id": "t02_100",
"componentId": "Basic_SetVariable",
"label": "记录异常结果",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${[\"02-11\", \"Basic_Dodecryption\", \"FAIL\", \"{\\\"words\\\":\\\"UlBBLURvZGVjcnlwdGlvbg==\\\"}\", \"Base64 解码 \\\"RPA-Dodecryption\\\"\", error.reason]}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "case_11"
},
"parentId": "t02_95",
"slot": "except_fn"
},
{
"id": "t02_101",
"componentId": "Basic_SetVariable",
"label": "异常累计失败数",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${failures + (case_11[2] === \"FAIL\" ? 1 : 0)}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "failures"
},
"parentId": "t02_95",
"slot": "except_fn"
},
{
"id": "t02_102",
"componentId": "Basic_SetVariable",
"label": "异常累计待确认数",
"position": {
"x": 220,
"y": 4580
},
"values": {
"value": "${pending + (case_11[2] === \"MANUAL\" ? 1 : 0)}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false,
"outputVar": "pending"
},
"parentId": "t02_95",
"slot": "except_fn"
},
{
"id": "t02_103",
"componentId": "Log_Info", "componentId": "Log_Info",
"label": "输出测试结果", "label": "输出测试结果",
"position": { "position": {
"x": 220, "x": 220,
"y": 8488 "y": 9130
},
"values": {
"msg": "用例结果:${json(case_11)}"
},
"options": {
"timeoutMs": 15000,
"retryCount": 0,
"logException": false
}
},
{
"id": "t02_104",
"componentId": "Log_Info",
"label": "输出测试结果",
"position": {
"x": 220,
"y": 9272
}, },
"values": { "values": {
"msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。" "msg": "本组汇总:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。"
@@ -1823,12 +1995,12 @@
} }
}, },
{ {
"id": "t02_96", "id": "t02_105",
"componentId": "logic_control_if_else", "componentId": "logic_control_if_else",
"label": "汇总判定", "label": "汇总判定",
"position": { "position": {
"x": 220, "x": 220,
"y": 8630 "y": 9414
}, },
"values": { "values": {
"condition": "${failures > 0 || pending > 0 || blocked > 0}" "condition": "${failures > 0 || pending > 0 || blocked > 0}"
@@ -1840,12 +2012,12 @@
} }
}, },
{ {
"id": "t02_97", "id": "t02_106",
"componentId": "ExceptionUtil_Throws", "componentId": "ExceptionUtil_Throws",
"label": "如实标记未通过", "label": "如实标记未通过",
"position": { "position": {
"x": 220, "x": 220,
"y": 4760 "y": 5120
}, },
"values": { "values": {
"msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。" "msg": "组件测试未全部通过:失败 ${failures} 项,阻塞 ${blocked} 项,待人工确认 ${pending} 项。请查看日志。"
@@ -1855,16 +2027,16 @@
"retryCount": 0, "retryCount": 0,
"logException": false "logException": false
}, },
"parentId": "t02_96", "parentId": "t02_105",
"slot": "statement_if" "slot": "statement_if"
}, },
{ {
"id": "t02_98", "id": "t02_107",
"componentId": "Log_Info", "componentId": "Log_Info",
"label": "输出测试结果", "label": "输出测试结果",
"position": { "position": {
"x": 220, "x": 220,
"y": 4760 "y": 5120
}, },
"values": { "values": {
"msg": "本组全部通过。" "msg": "本组全部通过。"
@@ -1874,7 +2046,7 @@
"retryCount": 0, "retryCount": 0,
"logException": false "logException": false
}, },
"parentId": "t02_96", "parentId": "t02_105",
"slot": "statement_else" "slot": "statement_else"
} }
], ],
@@ -2000,9 +2172,19 @@
"target": "t02_95" "target": "t02_95"
}, },
{ {
"id": "e_t02_96", "id": "e_t02_103",
"source": "t02_95", "source": "t02_95",
"target": "t02_96" "target": "t02_103"
},
{
"id": "e_t02_104",
"source": "t02_103",
"target": "t02_104"
},
{
"id": "e_t02_105",
"source": "t02_104",
"target": "t02_105"
} }
], ],
"meta": { "meta": {
+2 -1
View File
@@ -102,7 +102,7 @@ export class TestFlow {
export function buildSuite() { export function buildSuite() {
const groups = []; const groups = [];
groups.push(processFlow()); groups.push(processFlow());
const conversion = new TestFlow(2, "数据转换", ["Basic_StringToNum", "Basic_ConvertNumber", "Basic_ConvertInt", "Basic_ConvertBool", "Basic_ConvertString", "Basic_DataTypeConversion"]); const conversion = new TestFlow(2, "数据转换", ["Basic_StringToNum", "Basic_ConvertNumber", "Basic_ConvertInt", "Basic_ConvertBool", "Basic_ConvertString", "Basic_DataTypeConversion", "Basic_Dodecryption"]);
conversion.case("Basic_StringToNum", { val: "123.5" }, "数字 123.5", "actual_1 === 123.5"); conversion.case("Basic_StringToNum", { val: "123.5" }, "数字 123.5", "actual_1 === 123.5");
conversion.case("Basic_ConvertNumber", { val: "-42.25" }, "数字 -42.25", "actual_2 === -42.25"); conversion.case("Basic_ConvertNumber", { val: "-42.25" }, "数字 -42.25", "actual_2 === -42.25");
conversion.case("Basic_ConvertInt", { val: -3.9 }, "整数 -3(向零截断)", "actual_3 === -3"); conversion.case("Basic_ConvertInt", { val: -3.9 }, "整数 -3(向零截断)", "actual_3 === -3");
@@ -113,6 +113,7 @@ export function buildSuite() {
conversion.case("Basic_DataTypeConversion", { val: "-3.9", into_type: "int" }, "整数 -3", "actual_8 === -3"); conversion.case("Basic_DataTypeConversion", { val: "-3.9", into_type: "int" }, "整数 -3", "actual_8 === -3");
conversion.case("Basic_DataTypeConversion", { val: "false", into_type: "bool" }, "布尔 false", "actual_9 === false"); conversion.case("Basic_DataTypeConversion", { val: "false", into_type: "bool" }, "布尔 false", "actual_9 === false");
conversion.case("Basic_DataTypeConversion", { val: 42, into_type: "string" }, '文本 "42"', 'actual_10 === "42"'); conversion.case("Basic_DataTypeConversion", { val: 42, into_type: "string" }, '文本 "42"', 'actual_10 === "42"');
conversion.case("Basic_Dodecryption", { words: "UlBBLURvZGVjcnlwdGlvbg==" }, 'Base64 解码 "RPA-Dodecryption"', 'actual_11 === "RPA-Dodecryption"');
groups.push(conversion.finish()); groups.push(conversion.finish());
const judge = new TestFlow(3, "数据判断", ["Basic_IsDictionary", "Basic_IsNumber", "Basic_IsNull", "Basic_IsArray", "Basic_Type", "Basic_JudgeDataType"]); const judge = new TestFlow(3, "数据判断", ["Basic_IsDictionary", "Basic_IsNumber", "Basic_IsNull", "Basic_IsArray", "Basic_Type", "Basic_JudgeDataType"]);
for (const [id, val, expected] of [ for (const [id, val, expected] of [
+12 -5
View File
@@ -18,7 +18,7 @@ export function readBundle() {
return { manifest, groups: manifest.groups.map(entry => ({ entry, flow: JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8")) })) }; return { manifest, groups: manifest.groups.map(entry => ({ entry, flow: JSON.parse(fs.readFileSync(path.join(bundleRoot, entry.file), "utf8")) })) };
} }
// 静态清单供离线校验;真实运行前再与 Electron 返回的 implemented 清单逐项比较。 // 静态清单供离线校验;真实运行前再与运行时注册清单逐项比较。
export function staticCatalog() { export function staticCatalog() {
const registered = new Set(); const registered = new Set();
const directory = path.join(projectRoot, "src/main/capabilities"); const directory = path.join(projectRoot, "src/main/capabilities");
@@ -35,7 +35,7 @@ export function staticCatalog() {
visit(source); visit(source);
} }
const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "resources/catalog.json"), "utf8")); const catalog = JSON.parse(fs.readFileSync(path.join(projectRoot, "resources/catalog.json"), "utf8"));
for (const c of catalog.components) c.implemented = containers.has(c.id) || registered.has(c.id) || registered.has(c.functionName); for (const c of catalog.components) c.registered = containers.has(c.id) || registered.has(c.id) || registered.has(c.functionName);
return catalog; return catalog;
} }
@@ -47,9 +47,13 @@ export async function validator() {
export async function verifyBundle(catalog = staticCatalog()) { export async function verifyBundle(catalog = staticCatalog()) {
const { manifest, groups } = readBundle(); const { manifest, groups } = readBundle();
const expected = catalog.components.filter(c => c.group === "app" && c.implemented).map(c => c.id).sort(); const expected = catalog.components.filter(c => c.group === "app" && (c.registered || c.implemented)).map(c => c.id).sort();
const actual = groups.flatMap(g => g.entry.components).sort(); const actual = groups.flatMap(g => g.entry.components).sort();
if (groups.length !== 13 || actual.length !== 49 || new Set(actual).size !== 49 || JSON.stringify(actual) !== JSON.stringify(expected)) throw new Error("流程包与当前 49 项可执行清单不一致,请重新生成并核对覆盖范围。"); if (groups.length !== 13 || actual.length !== 50 || new Set(actual).size !== 50 || JSON.stringify(actual) !== JSON.stringify(expected)) {
const expectedOnly = expected.filter(id => !actual.includes(id));
const actualOnly = actual.filter(id => !expected.includes(id));
throw new Error(`流程包与当前 50 项验收清单不一致;expectedOnly=${JSON.stringify(expectedOnly)};actualOnly=${JSON.stringify(actualOnly)}`);
}
const validateFlow = await validator(); const validateFlow = await validator();
const validation = {}; const validation = {};
for (const { flow, entry } of groups) { for (const { flow, entry } of groups) {
@@ -248,6 +252,9 @@ export async function runSafe(outputDirectory, checkUi = false) {
const info = await evaluate(page, "window.rpa.app.info()"); const info = await evaluate(page, "window.rpa.app.info()");
if (path.resolve(info.userData).toLowerCase() !== profile.toLowerCase()) throw new Error("隔离用户目录不匹配,已拒绝执行测试。"); if (path.resolve(info.userData).toLowerCase() !== profile.toLowerCase()) throw new Error("隔离用户目录不匹配,已拒绝执行测试。");
const catalog = await evaluate(page, "window.rpa.catalog.get()"); const catalog = await evaluate(page, "window.rpa.catalog.get()");
const runtime = await evaluate(page, "window.rpa.run.capabilities()");
const registered = new Set(runtime.ids || []);
for (const component of catalog.components) component.registered = registered.has(component.id) || registered.has(component.functionName);
const checked = await verifyBundle(catalog); const checked = await verifyBundle(catalog);
for (const { flow, entry } of checked.groups) { for (const { flow, entry } of checked.groups) {
const issues = checked.validation[entry.id]; const issues = checked.validation[entry.id];
@@ -323,7 +330,7 @@ export function makeReport(evidence) {
const counts = Object.fromEntries(priority.map(s => [s, rows.filter(r => r.status === s).length])); const counts = Object.fromEntries(priority.map(s => [s, rows.filter(r => r.status === s).length]));
const escape = value => String(value ?? "无执行记录").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ").slice(0, 1200); const escape = value => String(value ?? "无执行记录").replace(/\|/g, "\\|").replace(/[\r\n]+/g, " ").slice(0, 1200);
const recordedAt = evidence.generatedAt ? new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", dateStyle: "medium", timeStyle: "medium", hour12: false }).format(new Date(evidence.generatedAt)) + "(北京时间)" : "未执行"; const recordedAt = evidence.generatedAt ? new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", dateStyle: "medium", timeStyle: "medium", hour12: false }).format(new Date(evidence.generatedAt)) + "(北京时间)" : "未执行";
const lines = ["# 应用与系统组件测试报告", "", `记录时间:${recordedAt}。覆盖 49 项可执行组件,13 组任务。`, "", const lines = ["# 应用与系统组件测试报告", "", `记录时间:${recordedAt}。覆盖 50 项验收组件,13 组任务。`, "",
`按组件统计:通过 ${counts.PASS},失败 ${counts.FAIL},阻塞 ${counts.BLOCKED},待人工确认 ${counts.MANUAL},未运行 ${counts.UNRUN}。`, "", `按组件统计:通过 ${counts.PASS},失败 ${counts.FAIL},阻塞 ${counts.BLOCKED},待人工确认 ${counts.MANUAL},未运行 ${counts.UNRUN}。`, "",
"状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。", "", "状态依据是断言、前置校验和人工证据,节点执行成功不直接代表功能通过。未执行手动系统操作。", "",
"| 组 | 组件 ID | 名称 | 状态 |", "|---|---|---|---|"]; "| 组 | 组件 ID | 名称 | 状态 |", "|---|---|---|---|"];
+7 -7
View File
@@ -11,7 +11,7 @@ import { validateFlow } from "../src/renderer/src/validation/flowValidation.ts";
const containers = new Set(["logic_control_try_catch", "logic_control_if_else"]); const containers = new Set(["logic_control_try_catch", "logic_control_if_else"]);
const catalog = JSON.parse(fs.readFileSync("resources/catalog.json", "utf8")); const catalog = JSON.parse(fs.readFileSync("resources/catalog.json", "utf8"));
for (const c of catalog.components) c.implemented = containers.has(c.id) || hasCapability(c.id) || hasCapability(c.functionName); const isRegistered = (component) => containers.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName);
const componentMap = new Map(catalog.components.map(c => [c.id, c])); const componentMap = new Map(catalog.components.map(c => [c.id, c]));
const temporary = []; const temporary = [];
afterEach(() => { for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); }); afterEach(() => { for (const dir of temporary.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); });
@@ -29,13 +29,13 @@ async function execute(number, change) {
} }
describe("应用与系统测试流程包", () => { describe("应用与系统测试流程包", () => {
it("覆盖真实注册的全部 49 项,13 组无重复归属,不覆盖待接入组件", () => { it("覆盖真实注册的全部 50 项,13 组无重复归属,不覆盖备份组件", () => {
const suite = buildSuite(); const suite = buildSuite();
expect(suite).toHaveLength(13); expect(suite).toHaveLength(13);
const covered = suite.flatMap(group => group.entry.components).sort(); const covered = suite.flatMap(group => group.entry.components).sort();
const executable = catalog.components.filter(c => c.group === "app" && c.implemented).map(c => c.id).sort(); const executable = catalog.components.filter(c => c.group === "app" && isRegistered(c)).map(c => c.id).sort();
expect(covered).toHaveLength(49); expect(covered).toHaveLength(50);
expect(new Set(covered).size).toBe(49); expect(new Set(covered).size).toBe(50);
expect(covered).toEqual(executable); expect(covered).toEqual(executable);
for (const { flow, entry } of suite) { for (const { flow, entry } of suite) {
expect(new Set(flow.nodes.map(node => node.id)).size).toBe(flow.nodes.length); expect(new Set(flow.nodes.map(node => node.id)).size).toBe(flow.nodes.length);
@@ -87,8 +87,8 @@ describe("应用与系统测试流程包", () => {
expect(result.status).toBe("succeeded"); expect(result.status).toBe("succeeded");
expect(result.variables.failures).toBe(0); expect(result.variables.failures).toBe(0);
expect(result.variables.blocked).toBe(0); expect(result.variables.blocked).toBe(0);
expect(group.entry.cases.map(c => result.variables[c.resultVariable][2])).toEqual(Array(10).fill("PASS")); expect(group.entry.cases.map(c => result.variables[c.resultVariable][2])).toEqual(Array(11).fill("PASS"));
expect(logs.filter(log => log.startsWith("用例结果:"))).toHaveLength(10); expect(logs.filter(log => log.startsWith("用例结果:"))).toHaveLength(11);
}); });
it("注入错误返回类型时断言会失败,且前面的用例继续留有证据", async () => { it("注入错误返回类型时断言会失败,且前面的用例继续留有证据", async () => {
+14
View File
@@ -136,6 +136,20 @@ function groupFor(component) {
return "data"; return "data";
} }
function normalizeOptions(value) {
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === "string") return { label: item, value: item };
if (typeof item !== "object" || item === null) return null;
const label = String(item.label ?? item.text ?? item.value ?? "");
const rawValue = item.value ?? item.label ?? "";
return { label, value: cleanDefault(rawValue) };
}).filter((item) => item.label || item.value);
}
if (Array.isArray(value?.options)) return normalizeOptions(value.options);
return [];
}
function normalizeVendorField(field) { function normalizeVendorField(field) {
const widget = widgetFor(field.displayType, field.widget); const widget = widgetFor(field.displayType, field.widget);
const valueTypes = Array.isArray(field.valueTypes) ? [...field.valueTypes] : []; const valueTypes = Array.isArray(field.valueTypes) ? [...field.valueTypes] : [];
+160
View File
@@ -0,0 +1,160 @@
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const root = process.cwd();
const debugPort = 9233;
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-studio-desktop-e2e-"));
const workDir = path.join(os.tmpdir(), "rpa-desktop-test");
const noteFile = path.join(workDir, "rpa-desktop-test.txt");
const titleMarker = "rpa-desktop-test";
const typedText = "RPA-Keyboard-Mouse-Test-OK";
const uiaText = "UIA-SetValue-写入验证";
const candidates = [process.env.RPA_ELECTRON_DIST, "C:/electron-rpa/dist", path.join(root, "node_modules", "electron", "dist")].filter(Boolean);
const dist = candidates.find((candidate) => fs.existsSync(path.join(candidate, "electron.exe")));
if (!dist) throw new Error("找不到 Electron 运行时,请设置 RPA_ELECTRON_DIST");
const env = { ...process.env, RPA_E2E: "1" };
delete env.ELECTRON_RUN_AS_NODE;
const child = spawn(path.join(dist, "electron.exe"), [root, "--no-sandbox", `--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`], {
cwd: root, env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true
});
let stderr = "";
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
async function waitForTarget() {
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`RPA Studio 提前退出:${child.exitCode}\n${stderr}`);
try {
const targets = await (await fetch(`http://127.0.0.1:${debugPort}/json/list`)).json();
const page = targets.find((item) => item.type === "page" && item.title === "RPA Studio");
if (page) return page;
} catch { /* wait for CDP */ }
await new Promise((resolve) => setTimeout(resolve, 200));
}
throw new Error(`等待 RPA Studio CDP 超时\n${stderr}`);
}
async function evaluate(page, expression) {
const socket = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
const result = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("E2E Runtime.evaluate 超时")), 180000);
socket.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.id !== 1) return;
clearTimeout(timer);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else if (message.result?.exceptionDetails) reject(new Error(message.result.exceptionDetails.text || "E2E 页面异常"));
else resolve(message.result?.result?.value);
};
socket.send(JSON.stringify({ id: 1, method: "Runtime.evaluate", params: { expression, returnByValue: true, awaitPromise: true } }));
});
socket.close();
return result;
}
function node(componentId, values, label, outputVar, timeoutMs = 20000) {
return { componentId, values, label, position: { x: 220, y: 0 }, options: { timeoutMs, logException: true, ...(outputVar ? { outputVar } : {}) } };
}
const maximizeScript = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaMax -Namespace WinRpaMax | Out-Null; $p = Get-Process notepad | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1; if ($p) { [WinRpaMax.RpaMax]::ShowWindow($p.MainWindowHandle, 3) | Out-Null; 'maximized' } else { 'no-window' }";
const cursorPosScript = "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; \"$($p.X),$($p.Y)\"";
const nodes = [
node("Trigger.Manual", { entryName: "开始桌面键鼠实测" }, "人工发起"),
node("System_ExecuteCommand", { action: `New-Item -ItemType Directory -Force -Path '${workDir}' | Out-Null; Set-Content -LiteralPath '${noteFile}' -Value '' -Encoding UTF8` }, "准备测试文件"),
node("App_Run", { executeable: "notepad.exe", params: noteFile }, "启动记事本", "notepadPid"),
node("Basic_Delay", { mileSecond: 1500 }, "等待窗口出现"),
node("System_ExecuteCommand", { action: maximizeScript }, "最大化记事本", "maximized", 15000),
node("Window_SetActive", { match_title: titleMarker }, "激活记事本", "activatedTitle"),
node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"),
node("Window_WindowExists", { match_title: titleMarker }, "窗口存在", "exists1"),
node("Keyboard_Send", { keys: typedText }, "键盘输入英文", "typed"),
node("Keyboard_Send", { keys: "^a^c" }, "全选并复制"),
node("Clipboard_GetText", {}, "读取剪贴板", "clipboard1"),
node("Mouse_MoveTo", { position: "400,300" }, "鼠标移动", "movedTo"),
node("System_ExecuteCommand", { action: cursorPosScript }, "读取真实光标位置", "cursorPos", 15000),
node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "rolled"),
node("Window_SetActive", { match_title: titleMarker }, "重新激活记事本", "activatedTitle2"),
node("Element_ExistsGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 元素存在", "uiaExists"),
node("Element_SetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1`, value: uiaText }, "UIA 写入文本"),
node("Keyboard_Send", { keys: "^a^c" }, "再次全选复制"),
node("Clipboard_GetText", {}, "读取剪贴板二", "clipboard2"),
node("Element_GetValueGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 读取文本", "uiaValue"),
node("Element_ClickGround", { uiSelector: `window=${titleMarker};type=Edit;index=1` }, "UIA 点击编辑区", "clickResult"),
node("Window_SetActive", { match_title: titleMarker }, "清理前重新激活", "activatedTitle3"),
node("Keyboard_Send", { keys: "^a{DEL}" }, "清空文本避免保存提示"),
node("Window_CloseWindow", { title: titleMarker }, "关闭记事本", "closed"),
node("Basic_Delay", { mileSecond: 800 }, "等待退出"),
node("Window_WindowExists", { match_title: titleMarker }, "窗口已消失", "exists2")
];
const expression = `(async () => {
const flow = await window.rpa.flows.create("桌面键鼠实测");
const list = ${JSON.stringify(nodes)};
flow.nodes = list.map((item, index) => ({ id: "n" + String(index + 1).padStart(2, "0"), ...item, position: { x: 220, y: 80 + index * 140 } }));
flow.edges = flow.nodes.slice(1).map((item, index) => ({ id: "e" + index, source: flow.nodes[index].id, target: item.id }));
await window.rpa.flows.save(flow);
const events = [];
const off = window.rpa.run.onEvent((event) => events.push(event));
const started = await window.rpa.run.start(flow);
const finished = await new Promise((resolve, reject) => {
const deadline = Date.now() + 150000;
const timer = setInterval(() => {
const event = events.find((item) => item.type === "run.finished");
if (event) { clearInterval(timer); resolve(event); }
else if (Date.now() > deadline) { clearInterval(timer); reject(new Error("桌面键鼠流程超时")); }
}, 100);
});
off();
await window.rpa.flows.delete(flow.id);
const byNode = {};
for (const event of events) if (event.type === "node.finished") byNode[event.nodeId] = event;
return { runId: started.runId, status: finished.status, durationMs: finished.durationMs, byNode, logs: events.filter((e) => e.type === "log").map((e) => e.message) };
})()`;
let page;
try {
page = await waitForTarget();
const result = await evaluate(page, expression);
if (result?.status !== "succeeded") throw new Error(`桌面键鼠流程失败:${JSON.stringify(result, null, 2)}`);
const byNode = result.byNode || {};
const out = (id) => byNode[id]?.output;
const failedNodes = Object.entries(byNode).filter(([, event]) => event.status !== "succeeded");
const checks = [
["App_Run 返回 PID", Number(out("n03")) > 0, out("n03")],
["最大化记事本", out("n05") === "maximized", out("n05")],
["Window_SetActive 激活成功", String(out("n06") || "").includes(titleMarker), out("n06")],
["Window_GetActive 前台=记事本", String(out("n07") || "").includes(titleMarker), out("n07")],
["Window_WindowExists=true", out("n08") === true, out("n08")],
["Keyboard_Send 输入回显", out("n09") === typedText, out("n09")],
["剪贴板=键盘输入内容", out("n11") === typedText, out("n11")],
["Mouse_MoveTo 返回坐标", JSON.stringify(out("n12")) === JSON.stringify({ x: 400, y: 300 }), out("n12")],
["系统真实光标=目标位置", out("n13") === "400,300", out("n13")],
["Mouse_RollTo 滚动执行", out("n14") === 120, out("n14")],
["重复激活仍成功", String(out("n15") || "").includes(titleMarker), out("n15")],
["UIA 元素存在", out("n16") === true, out("n16")],
["UIA SetValue 后剪贴板一致", out("n19") === uiaText, out("n19")],
["UIA GetValue 读取一致", out("n20") === uiaText, out("n20")],
["UIA Click 执行", out("n21") && typeof out("n21") === "object", out("n21")],
["Window_CloseWindow 关闭", out("n24") === true, out("n24")],
["关闭后窗口不存在", out("n26") === false, out("n26")]
];
const problems = checks.filter(([, ok]) => !ok).map(([name, , actual]) => `${name} → ${JSON.stringify(actual)}`);
if (problems.length || failedNodes.length) {
throw new Error(`桌面键鼠实测未通过:\n${[...failedNodes.map(([id, event]) => `${id} ${event.status}: ${JSON.stringify(event.error || event.output)}`), ...problems].join("\n")}\n日志:\n${(result.logs || []).join("\n")}`);
}
console.log(JSON.stringify({ ok: true, runId: result.runId, durationMs: result.durationMs, checks: checks.map(([name]) => name) }, null, 2));
} finally {
if (process.platform === "win32") {
spawnSync("taskkill", ["/IM", "notepad.exe", "/F"], { windowsHide: true, stdio: "ignore" });
if (child.pid) spawnSync("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
} else child.kill("SIGKILL");
await new Promise((resolve) => setTimeout(resolve, 500));
try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 12, retryDelay: 250 }); } catch {}
try { fs.rmSync(workDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 300 }); } catch {}
}
+106
View File
@@ -0,0 +1,106 @@
import fs from "node:fs";
import path from "node:path";
const appData = process.env.APPDATA || path.join(process.env.USERPROFILE || "", "AppData", "Roaming");
const flowsDir = path.join(appData, "rpa-studio", "flows");
const id = "flow_manual_desktop_keymouse";
const title = "manual-desktop";
if (!fs.existsSync(flowsDir)) {
throw new Error(`找不到流程目录:${flowsDir}。请先启动一次 RPA Studio。`);
}
let y = 80;
let sequence = 0;
function node(componentId, values, label, outputVar, options = {}) {
const item = {
id: `n${String(++sequence).padStart(2, "0")}`,
componentId,
label,
position: { x: 260, y },
values,
options: { timeoutMs: 20000, logException: true, ...(outputVar ? { outputVar } : {}), ...options }
};
y += 140;
return item;
}
function dialog(text, title = "人工确认") {
return node("Dialog_Notice", { title, text }, title, undefined, { timeoutMs: 600000 });
}
const nodes = [
node("Trigger.Manual", { entryName: "开始桌面键鼠人工测试" }, "人工发起"),
node("System_ExecuteCommand", {
action: "$old=@(Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowTitle -like '*manual-desktop*' }); foreach($p in $old){ $p | Stop-Process -Force }; if($old.Count){ Start-Sleep -Milliseconds 500; 'closed:'+$old.Count } else { 'no-old' }"
}, "清理旧测试窗口", "cleanupOld"),
node("System_ExecuteCommand", {
action: `$d=Join-Path $env:TEMP 'rpa-manual-desktop'; New-Item -ItemType Directory -Force -Path $d | Out-Null; $f=Join-Path $d '${title}.txt'; Set-Content -LiteralPath $f -Value '' -Encoding UTF8; Write-Output $f`
}, "准备记事本文件", "noteFile"),
node("App_Run", { executeable: "notepad.exe", params: "${noteFile}" }, "启动记事本", "notepadPid"),
node("Basic_Delay", { mileSecond: 1200 }, "等待窗口", "waited1"),
node("System_ExecuteCommand", {
action: `$p=Get-Process notepad -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like '*${title}*' } | Select-Object -First 1; if($p){ Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);' -Name RpaManualMax -Namespace WinRpaManualMax | Out-Null; [void][WinRpaManualMax.RpaManualMax]::ShowWindow($p.MainWindowHandle,3); 'maximized' } else { 'no-window' }`
}, "最大化记事本", "maximized"),
node("Window_SetActive", { match_title: title }, "自动激活记事本", "activatedTitle", { skipOnError: true }),
node("Window_GetActive", {}, "读取前台窗口", "activeTitle1"),
node("Window_WindowExists", { switchType: "0", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "检查窗口存在", "windowExists1"),
node("Basic_DebugOutput", { msg: "自动激活:${activatedTitle};前台:${activeTitle1};存在:${windowExists1}" }, "输出阶段1结果"),
dialog("阶段1 / 窗口能力\n\n预期:\n1. 屏幕上出现并最大化 manual-desktop.txt 记事本;\n2. 自动激活和前台标题包含 manual-desktop;\n3. 窗口存在为 true。\n\n确认无误后点确定。", "阶段1 · 窗口检查"),
dialog("阶段2 / 键盘准备\n\n请先点确定关闭本弹窗。\n关闭后 3 秒内,用鼠标点击一次记事本编辑区,但不要按键盘。", "阶段2 · 键盘准备"),
node("Basic_Delay", { mileSecond: 3000 }, "给手工聚焦留时间", "waited2"),
node("Keyboard_Send", { keys: "RPA-KEY-MANUAL-OK", words: "RPA-KEY-MANUAL-OK", interval: "0.01", fuctype: "1" }, "键盘输入英文", "keyboardResult"),
node("Keyboard_Send", { keys: "^a^c", words: "^a^c", interval: "0.01", fuctype: "1" }, "全选并复制", "shortcutResult"),
node("Clipboard_GetText", {}, "读取剪贴板", "clipboardText"),
node("Basic_DebugOutput", { msg: "键盘返回:${keyboardResult};剪贴板:${clipboardText}" }, "输出键盘结果"),
dialog("阶段2 / 键盘能力\n\n预期:\n1. 记事本显示 RPA-KEY-MANUAL-OK;\n2. 调试输出中的剪贴板也是 RPA-KEY-MANUAL-OK。\n\n确认后点确定。", "阶段2 · 键盘检查"),
node("Mouse_MoveTo", { position: "620,420" }, "鼠标移动", "mouseMoveResult"),
node("System_ExecuteCommand", {
action: "Add-Type -AssemblyName System.Windows.Forms; $p=[System.Windows.Forms.Cursor]::Position; Write-Output \"$($p.X),$($p.Y)\""
}, "核对光标位置", "realCursor"),
node("Mouse_Click", { position: "650,450" }, "鼠标左键点击", "mouseClickResult"),
node("Mouse_DragTo", { Start: "650,450", End: "760,520" }, "鼠标拖拽", "mouseDragResult"),
node("Mouse_RollTo", { RollNum: 1 }, "滚轮滚动", "mouseRollResult"),
dialog("阶段3 / 鼠标能力\n\n请回忆/观察:\n1. 光标曾移动到屏幕中部;\n2. 记事本编辑区收到左键点击;\n3. 拖拽执行且没有把窗口拖丢;\n4. 滚轮动作执行。\n\n确认后点确定。", "阶段3 · 鼠标检查"),
node("Element_ExistsGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 元素存在", "uiaExists", { skipOnError: true }),
node("Element_SetValueGround", { uiSelector: `window=${title};type=Edit;index=1`, value: "UIA-MANUAL-写入-OK" }, "UIA 写入编辑框", "uiaSetResult", { skipOnError: true }),
node("Element_GetValueGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 读取编辑框", "uiaValue", { skipOnError: true }),
node("Element_ClickGround", { uiSelector: `window=${title};type=Edit;index=1` }, "UIA 点击编辑框", "uiaClickResult", { skipOnError: true }),
node("Basic_DebugOutput", { msg: "UIA 存在:${uiaExists};读取:${uiaValue}" }, "输出UIA结果"),
dialog("阶段4 / UIA 能力\n\n预期:\n1. 记事本内容被写入 UIA-MANUAL-写入-OK;\n2. 调试输出中 UIA 存在为 true,读取值一致;\n3. UIA 点击执行后流程继续。\n\n确认后点确定。", "阶段4 · UIA检查"),
dialog("准备清理:流程会尝试清空内容并关闭记事本。点确定继续。", "清理确认"),
node("Window_SetActive", { match_title: title }, "清理前激活", "activatedTitle2", { skipOnError: true }),
node("Keyboard_Send", { keys: "^a{DEL}", words: "^a{DEL}", interval: "0.01", fuctype: "1" }, "清空文本", "clearResult"),
node("Window_CloseWindow", { title, window_obj: title }, "关闭记事本", "closeResult", { skipOnError: true }),
node("Basic_Delay", { mileSecond: 800 }, "等待退出", "waited3"),
node("Window_WindowExists", { switchType: "1", get_type: "2", window_obj: title, match_type: "1", match_title: title }, "确认窗口关闭", "windowExists2"),
node("Basic_DebugOutput", { msg: "关闭结果:${closeResult};关闭后窗口存在:${windowExists2}" }, "输出清理结果"),
dialog("阶段5 / 清理检查\n\n预期:记事本已关闭,关闭后窗口存在为 false。\n\n完成请点确定。", "阶段5 · 清理检查")
];
const flow = {
schemaVersion: 1,
id,
name: "桌面键鼠人工验收",
description: "分阶段验证窗口、键盘、鼠标、剪贴板、UIA 与清理;每个阶段有弹窗暂停,便于人工确认。",
enabled: true,
trigger: { type: "manual", label: "手动运行" },
variables: [],
nodes,
edges: nodes.slice(1).map((target, index) => ({ id: `e${index}`, source: nodes[index].id, target: target.id })),
meta: { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), appVersion: "0.1.0" }
};
const target = path.join(flowsDir, `${id}.json`);
fs.writeFileSync(target, JSON.stringify(flow, null, 2), "utf8");
console.log(`已写入:${target}`);
console.log(`节点数:${nodes.length}`);
+9 -2
View File
@@ -86,10 +86,10 @@ console.log("[package] typecheck");
run("npm", ["run", "typecheck"]); run("npm", ["run", "typecheck"]);
console.log("[package] tests"); console.log("[package] tests");
run("npm", ["test"]); run("npm", ["test"]);
console.log("[package] browser e2e");
run("npm", ["run", "test:e2e"]);
console.log("[package] build"); console.log("[package] build");
run("npm", ["run", "build"]); run("npm", ["run", "build"]);
console.log("[package] browser e2e");
run("npm", ["run", "test:e2e"]);
const electronDist = path.join(root, "node_modules", "electron", "dist"); const electronDist = path.join(root, "node_modules", "electron", "dist");
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) { if (!fs.existsSync(path.join(electronDist, "electron.exe"))) {
@@ -115,9 +115,13 @@ if (!fs.existsSync(path.join(builderUnpacked, "RPA Studio.exe"))) {
} }
const packagedCatalogPath = path.join(builderUnpacked, "resources", "catalog.json"); const packagedCatalogPath = path.join(builderUnpacked, "resources", "catalog.json");
const packagedFixturePath = path.join(builderUnpacked, "resources", "fixture", "index.html"); const packagedFixturePath = path.join(builderUnpacked, "resources", "fixture", "index.html");
const packagedPickerPath = path.join(builderUnpacked, "resources", "desktop-picker.ps1");
if (!fs.existsSync(packagedCatalogPath) || !fs.existsSync(packagedFixturePath)) { if (!fs.existsSync(packagedCatalogPath) || !fs.existsSync(packagedFixturePath)) {
throw new Error("打包产物缺少运行资源:resources/catalog.json 或 resources/fixture/index.html"); throw new Error("打包产物缺少运行资源:resources/catalog.json 或 resources/fixture/index.html");
} }
if (!fs.existsSync(packagedPickerPath)) {
throw new Error("打包产物缺少桌面拾取脚本:resources/desktop-picker.ps1 未随包发布");
}
const packagedCatalog = JSON.parse(fs.readFileSync(packagedCatalogPath, "utf8")); const packagedCatalog = JSON.parse(fs.readFileSync(packagedCatalogPath, "utf8"));
if (!Array.isArray(packagedCatalog.components) || packagedCatalog.components.length === 0) { if (!Array.isArray(packagedCatalog.components) || packagedCatalog.components.length === 0) {
throw new Error("打包产物 resources/catalog.json 无效:组件目录为空"); throw new Error("打包产物 resources/catalog.json 无效:组件目录为空");
@@ -216,6 +220,9 @@ if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) {
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "catalog.json"))) { if (!fs.existsSync(path.join(installSmokeTarget, "resources", "catalog.json"))) {
throw new Error("NSIS 安装包冒烟失败:catalog.json 未安装"); throw new Error("NSIS 安装包冒烟失败:catalog.json 未安装");
} }
if (!fs.existsSync(path.join(installSmokeTarget, "resources", "desktop-picker.ps1"))) {
throw new Error("NSIS 安装包冒烟失败:desktop-picker.ps1 未安装");
}
await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed"); await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed");
removeTree(installSmokeRoot); removeTree(installSmokeRoot);
+4 -1
View File
@@ -24,6 +24,8 @@ export interface ExternalLaunchOptions {
extraArgs?: string[]; extraArgs?: string[];
/** 独立用户数据目录;缺省使用系统临时目录下的独立 profile */ /** 独立用户数据目录;缺省使用系统临时目录下的独立 profile */
userDataDir?: string; userDataDir?: string;
/** 首次启动时直接打开的 URL,避免先出现 about:blank */
initialUrl?: string;
} }
export interface ExternalRemoteOptions { export interface ExternalRemoteOptions {
@@ -225,12 +227,13 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
await fs.mkdir(userDataDir, { recursive: true }); await fs.mkdir(userDataDir, { recursive: true });
const extraArgs = (options.extraArgs ?? []).filter(Boolean); const extraArgs = (options.extraArgs ?? []).filter(Boolean);
const startUrl = options.initialUrl?.trim() || "about:blank";
const args = [ const args = [
`--remote-debugging-port=${port}`, `--remote-debugging-port=${port}`,
`--user-data-dir=${userDataDir}`, `--user-data-dir=${userDataDir}`,
"--no-first-run", "--no-first-run",
"--no-default-browser-check", "--no-default-browser-check",
"about:blank", startUrl,
...extraArgs ...extraArgs
]; ];
+175 -35
View File
@@ -166,6 +166,16 @@ export class BrowserSession implements BrowserLike {
} }
/** 当前会话是否为外部浏览器 */ /** 当前会话是否为外部浏览器 */
/** 会话底层 CDP 是否已断开(缓存清理用) */
get closed(): boolean {
return this.transport.closed;
}
/** 缓存键,外部会话排序用 */
get sessionKey(): string {
return this.externalKey;
}
get isExternal(): boolean { get isExternal(): boolean {
return this.external !== null; return this.external !== null;
} }
@@ -233,6 +243,18 @@ export class BrowserSession implements BrowserLike {
return !name || accessibleName.includes(name); return !name || accessibleName.includes(name);
}) || null; }) || null;
} }
if (spec.kind === 'css') {
const segments = String(spec.value).split('>>>').map((item) => item.trim()).filter(Boolean);
let root = document;
for (let index = 0; index < segments.length; index += 1) {
const found = root.querySelector(segments[index]);
if (!found) return null;
if (index === segments.length - 1) return found;
root = found.shadowRoot;
if (!root) return null;
}
return null;
}
return document.querySelector(spec.value); return document.querySelector(spec.value);
}; };
const el = find(); const el = find();
@@ -269,24 +291,28 @@ export class BrowserSession implements BrowserLike {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; } try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
}; };
const cssPath = (node) => { const cssPath = (node) => {
if (node.id) { const localPath = (start, root) => {
const candidate = '#' + cssEscape(node.id); const stable = (item) => {
if (isUnique(candidate)) return candidate; if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
} }
const testId = node.getAttribute('data-testid'); const testId = item.getAttribute('data-testid');
if (testId) { if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]'; const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate; try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
} }
return null;
};
const parts = []; const parts = [];
let current = node; let current = start;
while (current && current.nodeType === 1) { while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase(); const stableCandidate = stable(current);
if (current.id) { if (stableCandidate) {
part += '#' + cssEscape(current.id); parts.unshift(stableCandidate);
parts.unshift(part); return parts.join(' > ');
break;
} }
let part = current.tagName.toLowerCase();
const parent = current.parentElement; const parent = current.parentElement;
if (parent) { if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName); const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
@@ -297,6 +323,15 @@ export class BrowserSession implements BrowserLike {
} }
return parts.join(' > '); return parts.join(' > ');
}; };
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return trees.join(' >>> ');
};
const describe = (node) => ({ const describe = (node) => ({
kind: 'css', kind: 'css',
value: cssPath(node), value: cssPath(node),
@@ -1132,6 +1167,10 @@ export class BrowserSession implements BrowserLike {
let box = null; let box = null;
let badge = null; let badge = null;
let target = null; let target = null;
let locked = false;
let chain = [];
let chainIndex = 0;
let selected = null;
let timer = 0; let timer = 0;
let finished = false; let finished = false;
@@ -1143,24 +1182,28 @@ export class BrowserSession implements BrowserLike {
try { return document.querySelectorAll(candidate).length === 1; } catch { return false; } try { return document.querySelectorAll(candidate).length === 1; } catch { return false; }
}; };
const cssPath = (node) => { const cssPath = (node) => {
if (node.id) { const localPath = (start, root) => {
const candidate = '#' + cssEscape(node.id); const stable = (item) => {
if (isUnique(candidate)) return candidate; if (item.id) {
const candidate = '#' + cssEscape(item.id);
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
} }
const testId = node.getAttribute('data-testid'); const testId = item.getAttribute('data-testid');
if (testId) { if (testId) {
const candidate = '[data-testid="' + cssEscape(testId) + '"]'; const candidate = '[data-testid="' + cssEscape(testId) + '"]';
if (isUnique(candidate)) return candidate; try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
} }
return null;
};
const parts = []; const parts = [];
let current = node; let current = start;
while (current && current.nodeType === 1) { while (current && current.nodeType === 1) {
let part = current.tagName.toLowerCase(); const stableCandidate = stable(current);
if (current.id) { if (stableCandidate) {
part += '#' + cssEscape(current.id); parts.unshift(stableCandidate);
parts.unshift(part); return parts.join(' > ');
break;
} }
let part = current.tagName.toLowerCase();
const parent = current.parentElement; const parent = current.parentElement;
if (parent) { if (parent) {
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName); const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
@@ -1171,6 +1214,15 @@ export class BrowserSession implements BrowserLike {
} }
return parts.join(' > '); return parts.join(' > ');
}; };
const trees = [];
let current = node;
while (current && current.nodeType === 1) {
const root = current.getRootNode();
trees.unshift(localPath(current, root));
current = root instanceof ShadowRoot ? root.host : null;
}
return trees.join(' >>> ');
};
const describe = (node) => { const describe = (node) => {
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
return { return {
@@ -1187,37 +1239,95 @@ export class BrowserSession implements BrowserLike {
url: location.href url: location.href
}; };
}; };
const nodeName = (node) => node.getAttribute('aria-label') || node.getAttribute('placeholder') || node.getAttribute('name') || node.id || (node.innerText || '').trim().slice(0, 60) || '';
const updateBox = () => { const updateBox = () => {
if (!target || !box || !badge) return; const node = locked ? selected : target;
const rect = target.getBoundingClientRect(); if (!node || !box || !badge) return;
const rect = node.getBoundingClientRect();
box.style.left = Math.max(0, rect.left - 3) + 'px'; box.style.left = Math.max(0, rect.left - 3) + 'px';
box.style.top = Math.max(0, rect.top - 3) + 'px'; box.style.top = Math.max(0, rect.top - 3) + 'px';
box.style.width = Math.max(4, rect.width + 6) + 'px'; box.style.width = Math.max(4, rect.width + 6) + 'px';
box.style.height = Math.max(4, rect.height + 6) + 'px'; box.style.height = Math.max(4, rect.height + 6) + 'px';
const tag = target.tagName.toLowerCase(); const tag = node.tagName.toLowerCase();
const name = target.getAttribute('aria-label') || target.getAttribute('placeholder') || target.getAttribute('name') || target.id || ''; const name = nodeName(node);
badge.textContent = '拾取:' + tag + (name ? ' · ' + name.slice(0, 60) : ''); if (locked) {
badge.textContent = '已锁定 ' + (chainIndex + 1) + '/' + chain.length + ' · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [PageUp/PageDown]调层级 [空格]确认 [Esc]取消';
} else {
badge.textContent = '拾取 · ' + tag + (name ? ' · ' + name.slice(0, 42) : '') + ' · [空格]锁定 [Esc]取消';
}
badge.style.left = Math.max(0, rect.left) + 'px'; badge.style.left = Math.max(0, rect.left) + 'px';
badge.style.top = Math.max(0, rect.top - 30) + 'px'; badge.style.top = Math.max(0, rect.top - 34) + 'px';
};
const interactiveSelector = [
'button', 'a', 'input', 'select', 'textarea', 'summary', 'label', 'option',
'[role]', '[onclick]', '[contenteditable]', '[tabindex]'
].join(',');
const closestInteractive = (start) => {
let node = start;
while (node && node.nodeType === 1) {
try { if (node.matches(interactiveSelector)) return node; } catch {}
if (node.parentElement) {
node = node.parentElement;
continue;
}
const root = node.getRootNode();
node = root instanceof ShadowRoot ? root.host : null;
}
return start;
}; };
const firstElement = (event) => { const firstElement = (event) => {
for (const item of event.composedPath()) if (item instanceof Element) return item; let deepest = null;
return event.target instanceof Element ? event.target : null; for (const item of event.composedPath()) {
if (item instanceof Element) {
deepest = item;
break;
}
}
if (!deepest && event.target instanceof Element) deepest = event.target;
return closestInteractive(deepest);
};
const buildChain = (node) => {
const result = [];
let current = node;
while (current && current.nodeType === 1) {
result.unshift(current);
const root = current.getRootNode();
current = root instanceof ShadowRoot ? root.host : null;
}
return result;
};
const lock = () => {
if (locked || !target) return;
locked = true;
chain = buildChain(target);
chainIndex = Math.max(0, chain.length - 1);
selected = chain[chainIndex] || target;
updateBox();
};
const adjustLevel = (delta) => {
if (!locked || !chain.length) return;
const next = Math.min(chain.length - 1, Math.max(0, chainIndex + delta));
if (next === chainIndex) return;
chainIndex = next;
selected = chain[chainIndex];
updateBox();
};
const confirm = () => {
if (!locked || !selected) return;
cleanup();
resolve(describe(selected));
}; };
const move = (event) => { const move = (event) => {
if (finished) return; if (finished || locked) return;
target = firstElement(event); target = firstElement(event);
updateBox(); updateBox();
}; };
const click = (event) => { const click = (event) => {
if (finished) return; if (finished) return;
const selected = firstElement(event); // 与桌面拾取对齐:鼠标只用于定位,空格锁定/确认,避免误触发页面按钮。
if (!selected) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
event.stopImmediatePropagation(); event.stopImmediatePropagation();
cleanup();
resolve(describe(selected));
}; };
const key = (event) => { const key = (event) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
@@ -1225,6 +1335,25 @@ export class BrowserSession implements BrowserLike {
event.stopPropagation(); event.stopPropagation();
cleanup(); cleanup();
reject(new Error('已取消元素拾取')); reject(new Error('已取消元素拾取'));
return;
}
if (event.code === 'Space' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
if (locked) confirm();
else lock();
return;
}
if (locked && event.key === 'PageUp' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(-1);
return;
}
if (locked && event.key === 'PageDown' && !event.repeat) {
event.preventDefault();
event.stopPropagation();
adjustLevel(1);
} }
}; };
const cleanup = () => { const cleanup = () => {
@@ -1255,6 +1384,7 @@ export class BrowserSession implements BrowserLike {
window.addEventListener('click', click, true); window.addEventListener('click', click, true);
window.addEventListener('scroll', updateBox, true); window.addEventListener('scroll', updateBox, true);
window.addEventListener('resize', updateBox); window.addEventListener('resize', updateBox);
window.addEventListener('keyup', () => {}, true);
window.addEventListener('keydown', key, true); window.addEventListener('keydown', key, true);
window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } }; window.__rpaElementPicker = { cancel: () => { cleanup(); reject(new Error('已取消元素拾取')); } };
timer = setTimeout(() => { timer = setTimeout(() => {
@@ -1308,6 +1438,16 @@ export async function externalBrowserSession(options: ExternalBrowserOptions): P
return BrowserSession.createExternal(options); return BrowserSession.createExternal(options);
} }
export function listExternalSessions(): BrowserSession[] {
return [...externalSessionCache().values()]
.filter((session) => session.isExternal && !session.closed)
.sort((left, right) => right.sessionKey.localeCompare(left.sessionKey));
}
export function latestExternalSession(): BrowserSession | null {
return listExternalSessions()[0] || null;
}
export function hasBrowserSession(): boolean { export function hasBrowserSession(): boolean {
return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed()); return Boolean(globalThis.__rpaBrowserWindow && !globalThis.__rpaBrowserWindow.isDestroyed());
} }
+85 -14
View File
@@ -1,33 +1,104 @@
import { loadSettings } from "../settings"; import { loadSettings } from "../settings";
import { RuntimeError } from "../engine/types"; import { RuntimeError } from "../engine/types";
export interface ChatMessage { role: "system" | "user" | "assistant"; content: string } export type ChatContentPart =
| { type: "text"; text: string }
| { type: "image_url"; image_url: { url: string } };
export function getAiConfig(): { baseUrl: string; apiKey: string; model: string } { export interface ChatMessage {
return loadSettings().aiConfig; role: "system" | "user" | "assistant";
content: string | ChatContentPart[];
} }
export function requireAiConfig() { export interface ResolvedAiConfig {
baseUrl: string;
apiKey: string;
model: string;
visionModel: string;
}
export function getAiConfig(): ResolvedAiConfig {
const config = loadSettings().aiConfig;
return { ...config, visionModel: config.visionModel || config.model };
}
export function requireAiConfig(options: { vision?: boolean } = {}): ResolvedAiConfig {
const config = getAiConfig(); const config = getAiConfig();
if (!config.baseUrl || !config.model) { if (!config.baseUrl || !config.model || (options.vision && !config.visionModel)) {
throw new RuntimeError("ai_not_configured", "AI 服务未配置", { recommendation: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)" }); throw new RuntimeError("ai_not_configured", "AI 服务未配置", {
recommendation: options.vision
? "到 设置 → AI 服务 填写 Base URL 与多模态视觉模型(兼容 OpenAI 协议)"
: "到 设置 → AI 服务 填写 Base URL 与模型名(兼容 OpenAI 协议)"
});
} }
return config; return config;
} }
export async function chatCompletion(messages: ChatMessage[], options: { temperature?: number } = {}): Promise<string> { export interface ChatCompletionOptions {
const config = requireAiConfig(); temperature?: number;
vision?: boolean;
/** 交互式请求(用户正在等待,如 AI 拾取):默认短超时 45s、最多 2 次,超时不重试 */
interactive?: boolean;
/** 单次请求超时毫秒数;显式指定后覆盖交互式/后台默认值 */
timeoutMs?: number;
/** 最大尝试次数;显式指定后覆盖交互式/后台默认值 */
maxAttempts?: number;
}
export async function chatCompletion(messages: ChatMessage[], options: ChatCompletionOptions = {}): Promise<string> {
const config = requireAiConfig({ vision: options.vision });
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const response = await fetch(base + "/chat/completions", { const interactive = options.interactive === true;
signal: AbortSignal.timeout(170000), const timeoutMs = Math.max(1000, Math.round(options.timeoutMs ?? (interactive ? 45000 : 170000)));
const maxAttempts = Math.max(1, Math.round(options.maxAttempts ?? (interactive ? 2 : 3)));
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
let response: Response;
try {
response = await fetch(base + "/chat/completions", {
signal: AbortSignal.timeout(timeoutMs),
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey }, headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
body: JSON.stringify({ model: config.model, messages, temperature: options.temperature ?? 0.3 }) body: JSON.stringify({
model: options.vision ? config.visionModel : config.model,
messages,
temperature: options.temperature ?? 0.3
})
}); });
if (!response.ok) { } catch (error) {
const detail = (await response.text()).slice(0, 300); // 超时(AbortSignal.timeout)不重试:重发只会成倍拉长等待
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail); if ((error as { name?: string }).name === "TimeoutError") {
throw new RuntimeError("ai_request_timeout", `AI 请求超时(${timeoutMs}ms),请检查网络或稍后重试`);
} }
if (attempt === maxAttempts) throw error;
await new Promise(resolve => setTimeout(resolve, 1200 * attempt));
continue;
}
if (response.ok) {
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> }; const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
return data.choices?.[0]?.message?.content ?? ""; return data.choices?.[0]?.message?.content ?? "";
} }
const detail = (await response.text()).slice(0, 300);
const retryable = response.status === 408 || response.status === 429 || response.status >= 500;
if (retryable && attempt < maxAttempts) {
const retryAfter = Number(response.headers.get("retry-after"));
const fallbackDelay = 1400 * attempt;
const delay = Number.isFinite(retryAfter) && retryAfter >= 0 && retryAfter <= 15
? retryAfter * 1000
: fallbackDelay;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
if (response.status === 429) {
const suffix = detail ? " 详情:" + detail.slice(0, 180) : "";
throw new RuntimeError("ai_rate_limited", "AI 服务限流或配额不足(HTTP 429),请稍后重试或更换可用视觉模型。" + suffix);
}
throw new RuntimeError("ai_request_failed", "AI 请求失败(HTTP " + response.status + "):" + detail);
}
throw new RuntimeError("ai_request_failed", "AI 请求失败:已重试仍未成功");
}
+50
View File
@@ -0,0 +1,50 @@
import { Buffer } from "node:buffer";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
const SM4_KEY = "INPUT_PARAM_KEY4";
const SM4_KEY_HEX = Buffer.from(SM4_KEY, "utf8").toString("hex");
function looksLikeBase64(value: string): boolean {
const text = value.trim().replace(/\s+/g, "");
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) return false;
return Buffer.from(text, "base64").toString("base64").replace(/=+$/, "") === text.replace(/=+$/, "");
}
function readableUtf8(value: string): boolean {
if (!value) return false;
let controls = 0;
for (const char of value) {
const code = char.codePointAt(0)!;
if (code < 32 && !["\n", "\r", "\t"].includes(char)) controls += 1;
if (code === 0xfffd) controls += 10;
}
return controls === 0;
}
async function sm4Decrypt(value: string): Promise<string> {
const { sm4 } = await import("sm-crypto");
const errors: string[] = [];
for (const padding of ["pkcs#7", "none"] as const) {
try {
const output = sm4.decrypt(value.toLowerCase(), SM4_KEY_HEX, { padding });
if (output && readableUtf8(output)) return output;
errors.push(`${padding}: empty or non-text output`);
} catch (error) {
errors.push(`${padding}: ${error instanceof Error ? error.message : String(error)}`);
}
}
throw new RuntimeError("decryption_failed", "SM4 密文解密失败", { detail: errors.join("; ") });
}
const dodecryption: CapabilityHandler = async (_ctx, node, args) => {
const words = String(args.words ?? args.value ?? args.text ?? "");
if (!words.trim()) throw new RuntimeError("validation_error", "密文内容为空", { nodeId: node.id, componentId: node.componentId });
if (looksLikeBase64(words)) {
const decoded = Buffer.from(words, "base64").toString("utf8");
if (readableUtf8(decoded)) return decoded;
}
return sm4Decrypt(words.trim());
};
register("Basic_Dodecryption", dodecryption);
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import "./desktop";
import "./basic-encryption";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) {
const handler = getHandler(id);
expect(handler, id).toBeTruthy();
return handler!(context, node(id), args, component);
}
describe("manual keyboard and encrypted variables", () => {
it("registers manual keyboard switches", async () => {
await expect(run("Keyboard_EnableManualOperation", { random_move: true, random_click: true, random_sleep: false, min_time: 0, max_time: 0 })).resolves.toMatchObject({ enabled: true });
await expect(run("Keyboard_DisableManualOperation", {})).resolves.toEqual({ enabled: false });
});
it("decodes base64 and SM4 ciphertext", async () => {
await expect(run("Basic_Dodecryption", { words: Buffer.from("hello", "utf8").toString("base64") })).resolves.toBe("hello");
const { sm4 } = await import("sm-crypto");
const key = Buffer.from("INPUT_PARAM_KEY4", "utf8").toString("hex");
const cipher = sm4.encrypt("secret", key, { padding: "pkcs#7" });
await expect(run("Basic_Dodecryption", { words: cipher })).resolves.toBe("secret");
await expect(run("Basic_Dodecryption", {})).rejects.toThrow();
});
});
+1 -18
View File
@@ -178,21 +178,4 @@ register("WebBrowser_Screenshot", async (ctx, node, args) => {
return file; return file;
}); });
register("Image_Screenshot", async (ctx, node, args) => { // Image_Screenshot 统一在 vision.ts 实现(屏幕/文件来源 + 区域裁剪),此处不再重复注册
const { desktopCapturer, screen } = await import("electron");
const folder = String(args.outImagePath ?? args.image_path ?? ctx.evidenceDir);
const name = String(args.outImageName ?? `${node.id}.png`);
const { promises: fs } = await import("node:fs");
const path = await import("node:path");
const target = path.resolve(folder.endsWith(".png") ? folder : path.join(folder, name));
const primary = screen.getPrimaryDisplay();
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: primary.size.width, height: primary.size.height }
});
const source = sources[0];
if (!source) throw new RuntimeError("desktop_error", "无法获取屏幕截图");
await fs.mkdir(path.dirname(target), { recursive: true });
await fs.writeFile(target, source.thumbnail.toPNG());
return target;
});
@@ -0,0 +1,89 @@
import type { Catalog, CatalogComponent, CatalogField } from "../../shared/types";
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { hasCapability } from "../engine/registry";
import "./index";
const widgets = new Set(["text","textarea","number","select","radio","boolean","file","folder","element","code","secret","region","hidden"]);
const runtimeContainers = new Set([
"logic_control_if_else","logic_control_count_for","logic_control_array_for_v3","logic_control_dictionary_for_v1",
"logic_control_while","infinite_loop","logic_control_try_catch"
]);
// 这些属性在运行时由上游对象 / 外部系统动态提供,组件库中退化为表达式输入。
const dynamicSelects = new Set([
"Database_BatchSQLExecute|connectionID","Database_BatchInsertExecute|connectionID","Database_SQLExecute|connectionID",
"DataService_Review|reviewTemplate","DataService_ChatBot|chatbot","DataService_ChatBot|botChannel",
"DataService_KeyWord|keyWordTemplate","DataService_ZBrain|z_barin","Element_Sibling_V2|position",
"Element_SetCheck|isChecked","Element_ElementAarray|browser_type","Excel_Cs_Interop|obj","Excel_Cs_Interop|property",
"Mail_DoReplyMail_v1|account_obj","Mail_FindMail_v1|email_num","Mail_DoGetMailMessage_v2|account_obj",
"Mail_DoGetMailMessage_v2|email_num","Mail_DoGetMailMessage_v1|account_obj","Mail_DoGetMailMessage_v1|email_num",
"Mail_FindMail_v2|email_num","Mail_DoMoveMail_v1|account_obj","Mail_DoSendMail_v1|account_obj",
"OCR_MergeCardOCR|category","OCR_MergeOtherOCR|category","OCR_MergePaperOCR|category","OCR_MergeCarOCR|category",
"OCR_MergeEducationOCR|category","WebBrowser_GetTAB|type","WebBrowser_GetTAB|isActive","WebBrowser_GetTAB|isRemote",
"WebBrowser_BatchDataCrawl|browserType"
]);
describe("catalog capability properties", () => {
it("exposes complete metadata for every canonical capability", async () => {
const catalog: Catalog = JSON.parse(await readFile("resources/catalog.json", "utf8"));
const excluded = JSON.parse(await readFile("resources/excluded.json", "utf8"));
const duplicates = JSON.parse(await readFile("resources/capability-duplicates.json", "utf8"));
const excludedIds = new Set(excluded.ids);
const hidden = new Set((duplicates.decisions as Array<{ id: string; status: string; canonical?: string }>).filter((item) => item.status === "hide").map((item) => item.id));
const visible: CatalogComponent[] = catalog.components.filter((component) => !excludedIds.has(component.id) && !hidden.has(component.id));
expect(visible).toHaveLength(654);
const ids = visible.map((component) => component.id);
expect(new Set(ids).size).toBe(ids.length);
for (const component of visible) {
expect(component.id, component.id).toBeTruthy();
expect(component.name, component.id).toBeTruthy();
expect(component.group, component.id).toBeTruthy();
expect(component.kind, component.id).toBeTruthy();
expect(component.icon, component.id).toBeTruthy();
expect(["low","high"], component.id).toContain(component.risk);
expect(Array.isArray(component.fields), component.id).toBe(true);
expect(Array.isArray(component.slots), component.id).toBe(true);
expect(component.output, component.id).toBeTruthy();
const fieldKeys = component.fields.map((field: CatalogField) => field.key);
expect(new Set(fieldKeys).size, component.id).toBe(fieldKeys.length);
for (const field of component.fields as CatalogField[]) {
expect(field.key, component.id).toBeTruthy();
expect(field.label, component.id + ":" + field.key).toBeTruthy();
expect(widgets.has(field.widget), component.id + ":" + field.key + ":" + field.widget).toBe(true);
expect(["string","number","boolean","object"], component.id + ":" + field.key).toContain(field.type);
if ((field.widget === "select" || field.widget === "radio") && !field.options?.length) {
expect(dynamicSelects.has(component.id + "|" + field.key), component.id + ":" + field.key).toBe(true);
}
if (Array.isArray(field.options) && field.options.length) {
const values = field.options.map((option: string | { label: string; value: string }) => typeof option === "string" ? option : option.value);
expect(values.every((value) => typeof value === "string"), component.id + ":" + field.key).toBe(true);
expect(new Set(values).size, component.id + ":" + field.key).toBe(values.length);
if (field.defaultValue) expect(values, component.id + ":" + field.key).toContain(field.defaultValue);
}
if (field.widget === "number" && field.defaultValue) expect(Number.isFinite(Number(field.defaultValue)), component.id + ":" + field.key).toBe(true);
if (field.widget === "boolean" && field.defaultValue) {
expect(["true","false","1","0"], component.id + ":" + field.key).toContain(String(field.defaultValue).toLowerCase());
}
}
const outputKeys = component.output.fields.map((field: { key: string }) => field.key);
expect(new Set(outputKeys).size).toBe(outputKeys.length);
}
});
it("binds every canonical capability to a runtime handler or container", async () => {
const catalog: Catalog = JSON.parse(await readFile("resources/catalog.json", "utf8"));
const excluded = JSON.parse(await readFile("resources/excluded.json", "utf8"));
const duplicates = JSON.parse(await readFile("resources/capability-duplicates.json", "utf8"));
const excludedIds = new Set(excluded.ids);
const hidden = new Set((duplicates.decisions as Array<{ id: string; status: string; canonical?: string }>).filter((item) => item.status === "hide").map((item) => item.id));
const visible: CatalogComponent[] = catalog.components.filter((component) => !excludedIds.has(component.id) && !hidden.has(component.id));
const missing = visible.filter((component) =>
!runtimeContainers.has(component.id) && !hasCapability(component.id) && !hasCapability(component.functionName)
);
expect(missing.map((component) => component.id)).toEqual([]);
});
});
+126
View File
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"ok":true}'));
const detectMock = vi.hoisted(() => vi.fn(async () => ({
texts: [
{ text: "确认保存", box: [[0, 0], [20, 0], [20, 10], [0, 10]] },
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
]
})));
// desktopCapturer 模拟: 默认无画面(走 GDI 回退), 多屏用例按请求宽度提供对应显示器画面
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
vi.mock("./desktop", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
vi.mock("electron", () => ({
desktopCapturer: {
getSources: vi.fn(async (opts: { thumbnailSize: { width: number } }) =>
electronState.sources.filter(s => s.thumbnail.getSize().width === opts.thumbnailSize.width))
},
screen: {
getAllDisplays: vi.fn(() => [
{ id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
{ id: 2, bounds: { x: 1920, y: 0, width: 1280, height: 720 }, scaleFactor: 2 }
]),
dipToScreenPoint: vi.fn((p: { x: number; y: number }) => ({ x: p.x, y: p.y }))
}
}));
import { getHandler } from "../engine/registry";
import "./cv-vision";
describe("CV vision capabilities", () => {
beforeEach(() => {
powershellMock.mockClear();
detectMock.mockClear();
electronState.sources = [];
});
it.each(["CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick"])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy();
});
it("rejects an empty CV selector", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n1", componentId: "CV_GetText" } as any, {}, {} as any))
.rejects.toThrow("CV 目标为空");
});
it("locates OCR text and clicks its center with offset", async () => {
const handler = getHandler("CV_MouseClick")!;
await expect(handler({ log: vi.fn() } as any, { id: "n1", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
offsetX: 5,
offsetY: 2
}, {} as any)).resolves.toBe(true);
expect(detectMock).toHaveBeenCalledTimes(1);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(15, 7)");
expect(script).toContain("mouse_event(0x0002");
});
it("returns matched OCR text", async () => {
await expect(getHandler("CV_GetText")!({ log: vi.fn() } as any, { id: "n2", componentId: "CV_GetText" } as any, {
cvSelector: "保存"
}, {} as any)).resolves.toBe("确认保存");
expect(powershellMock).toHaveBeenCalledTimes(1);
});
it("restricts matching to the search region when provided", async () => {
// 屏幕上有两处「确认」: 区域(0,0,50,50)内只应命中 (5,5) 那处, 而不是第一个全屏命中 (115,205)
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n3", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 0, y: 0, width: 50, height: 50 })
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[1]?.[0] ?? "");
expect(script).toContain("SetCursorPos(10, 5)");
});
it("rejects when target is outside the search region", async () => {
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n4", componentId: "CV_MouseClick" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 500, y: 500, width: 50, height: 50 })
}, {} as any)).rejects.toThrow("未找到目标文本");
});
it("locates targets on a secondary display and maps DIP coordinates", async () => {
// 主屏无画面, 副屏(bounds 1920,0 1280x720 缩放2)物理中心(450,210) → DIP(2145,105)
const fakeThumb = (w: number, h: number): any => ({
isEmpty: () => false,
getSize: () => ({ width: w, height: h }),
toPNG: () => Buffer.from([137, 80, 78, 71])
});
electronState.sources = [{ display_id: "2", thumbnail: fakeThumb(2560, 1440) }];
detectMock.mockImplementationOnce(async () => ({
texts: [{ text: "副屏按钮", box: [[400, 200], [500, 200], [500, 220], [400, 220]] }]
}));
await expect(getHandler("CV_MouseClick")!({ log: vi.fn() } as any, { id: "n5", componentId: "CV_MouseClick" } as any, {
cvSelector: "副屏按钮"
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
expect(script).toContain("SetCursorPos(2145, 105)");
});
it("maps region-image hits through the display scale", async () => {
// 框选图来自缩放2的副屏: 图内物理中心(120,90) + 框选DIP原点(1920,0) → DIP(1980,45)
const regionShot = path.join(os.tmpdir(), "rpa-cv-region-test-" + Date.now() + ".png");
fs.writeFileSync(regionShot, Buffer.from([137, 80, 78, 71]));
try {
detectMock.mockImplementationOnce(async () => ({
texts: [{ text: "确认", box: [[100, 80], [140, 80], [140, 100], [100, 100]] }]
}));
await expect(getHandler("CV_MouseHover")!({ log: vi.fn() } as any, { id: "n6", componentId: "CV_MouseHover" } as any, {
cvSelector: "确认",
searchRegion: JSON.stringify({ x: 1920, y: 0, width: 640, height: 360, scale: 2, imagePath: regionShot })
}, {} as any)).resolves.toBe(true);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
expect(script).toContain("SetCursorPos(1980, 45)");
} finally {
try { fs.unlinkSync(regionShot); } catch {}
}
});
});
+270
View File
@@ -0,0 +1,270 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell } from "./desktop";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
interface CvTarget { text: string; cx: number; cy: number }
let paddleOcr: { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> } | null = null;
async function getOcr() {
if (paddleOcr) return paddleOcr;
const mod = await import("@gutenye/ocr-node");
paddleOcr = await mod.default.create();
return paddleOcr as { detect: (image: string) => Promise<{ texts: Array<{ text: string; box?: number[][]; mean?: number }> }> };
}
function cvTarget(args: Record<string, unknown>, nodeId: string): string {
for (const key of ["cvSelector", "target", "text"]) {
const v = String(args[key] ?? "").trim();
if (v) return v.replace(/^text:/, "").replace(/^图片:/, "");
}
throw new RuntimeError("validation_error", "CV 目标为空", { nodeId, componentId: nodeId, recommendation: "填写要在屏幕上查找的目标文本" });
}
async function gdiGrabScreen(imagePath: string): Promise<void> {
const psScript = [
// 与点击脚本一致: 先声明 DPI 感知, 否则缩放非 100% 时 Bounds 是逻辑像素, 截屏只覆盖屏幕左上角
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);' -Name RpaCvDpi -Namespace RpaCv | Out-Null;",
"try { [RpaCv.RpaCvDpi]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}",
"Add-Type -AssemblyName System.Drawing | Out-Null;",
"Add-Type -AssemblyName System.Windows.Forms | Out-Null;",
"$b = [System.Windows.Forms.SystemInformation]::VirtualScreen;",
"$bmp = New-Object System.Drawing.Bitmap($b.Width, $b.Height);",
"$g = [System.Drawing.Graphics]::FromImage($bmp);",
"$g.CopyFromScreen($b.X, $b.Y, 0, 0, $b.Size);",
"$g.Dispose();",
"$bmp.Save(" + JSON.stringify(imagePath) + ");",
"$bmp.Dispose();"
].join(" ");
await powershell(psScript, 30000);
}
/** 一次屏幕截图: 图片左上角在虚拟桌面上的绝对 DIP 坐标 + 物理/DIP 缩放比 */
interface ScreenShot { imagePath: string; dipX: number; dipY: number; scale: number }
/** desktopCapturer 逐显示器截屏(每屏原生分辨率), 不可用时回退 GDI 全虚拟桌面单图 */
async function grabScreenAll(baseDir: string): Promise<ScreenShot[]> {
fs.mkdirSync(baseDir, { recursive: true });
try {
const { desktopCapturer, screen } = await import("electron");
if (desktopCapturer && screen?.getAllDisplays) {
const shots: ScreenShot[] = [];
const displays = screen.getAllDisplays();
for (const display of displays) {
const scale = display.scaleFactor || 1;
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) }
});
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === Math.round(display.bounds.width * scale))
|| sources[0];
if (!source || source.thumbnail.isEmpty()) continue;
const imagePath = path.join(baseDir, `rpa-cv-screen-${display.id}-${Date.now()}.png`);
fs.writeFileSync(imagePath, source.thumbnail.toPNG());
shots.push({ imagePath, dipX: display.bounds.x, dipY: display.bounds.y, scale });
}
if (shots.length) return shots;
}
} catch {}
const imagePath = path.join(baseDir, `rpa-cv-screen-${Date.now()}.png`);
await gdiGrabScreen(imagePath);
return [{ imagePath, dipX: 0, dipY: 0, scale: 1 }];
}
interface OcrLine { text: string; box?: number[][]; mean?: number }
/** 搜索区域: 框选生成时含 imagePath(区域截图)+x/y(屏幕绝对 DIP 坐标)+scale(该屏缩放); 也可只给坐标范围 */
interface CvRegion { imagePath?: string; x?: number; y?: number; width?: number; height?: number; scale?: number }
function parseRegionArg(value: unknown): CvRegion | null {
if (!value) return null;
if (typeof value === "object") return value as CvRegion;
const s = String(value).trim();
if (!s) return null;
if (s.startsWith("{")) {
try { return JSON.parse(s) as CvRegion; } catch { return null; }
}
if (/\.(png|jpe?g|bmp)$/i.test(s) || fs.existsSync(s)) return { imagePath: s };
return null;
}
function boxCenter(box: number[][]): { cx: number; cy: number } {
const xs = box.map(p => p[0]);
const ys = box.map(p => p[1]);
return { cx: (Math.min(...xs) + Math.max(...xs)) / 2, cy: (Math.min(...ys) + Math.max(...ys)) / 2 };
}
export async function locateTextOnScreen(
target: string,
nodeId: string,
region?: CvRegion | null
): Promise<{ target: string; text: string; cx: number; cy: number }> {
const ocr = await getOcr();
const keyword = target.trim();
// 命中中心换算为屏幕绝对 DIP 坐标
let found: { text: string; cx: number; cy: number } | null = null;
const cleanups: string[] = [];
if (region?.imagePath && fs.existsSync(region.imagePath)) {
// 框选截图: OCR 只识别该图; 图内物理坐标 ÷ 该屏缩放 + 框选绝对 DIP 原点
const scale = Number(region.scale ?? 1) || 1;
const result = await ocr.detect(region.imagePath);
const hit = (result.texts ?? []).find(t => t.text && t.text.includes(keyword) && t.box && t.box.length >= 4);
if (hit?.box) {
const c = boxCenter(hit.box);
found = {
text: hit.text,
cx: (Number(region.x ?? 0) || 0) + c.cx / scale,
cy: (Number(region.y ?? 0) || 0) + c.cy / scale
};
}
} else {
// 全屏查找: 逐显示器截屏识别, 覆盖副屏(含负坐标屏); 命中即停
const shots = await grabScreenAll(path.join(os.tmpdir(), "rpa-cv-shots"));
for (const shot of shots) {
const result = await ocr.detect(shot.imagePath);
const candidates = (result.texts ?? []).filter(t => t.text && t.text.includes(keyword) && t.box && t.box.length >= 4);
let hit: OcrLine | undefined = candidates[0];
// 给了坐标范围(绝对 DIP)时, 过滤中心落在区域内的命中
if (hit && region?.width && region?.height) {
const rx = Number(region.x ?? 0) || 0;
const ry = Number(region.y ?? 0) || 0;
hit = candidates.find(t => {
const c = boxCenter(t.box!);
const dx = shot.dipX + c.cx / shot.scale;
const dy = shot.dipY + c.cy / shot.scale;
return dx >= rx && dx <= rx + region.width! && dy >= ry && dy <= ry + region.height!;
});
}
if (hit?.box) {
const c = boxCenter(hit.box);
found = { text: hit.text, cx: shot.dipX + c.cx / shot.scale, cy: shot.dipY + c.cy / shot.scale };
break;
}
cleanups.push(shot.imagePath);
}
// 成功命中时清理全部截图, 未命中保留全部以便排查
if (found) for (const p of shots.map(s => s.imagePath)) { try { fs.unlinkSync(p); } catch {} }
}
if (!found) {
throw new RuntimeError("cv_not_found", "屏幕上未找到目标文本:" + keyword + (region?.imagePath ? "(搜索范围: 框选区域)" : ""), {
nodeId,
componentId: nodeId,
recommendation: "确认目标文本在" + (region?.imagePath ? "框选区域内" : "屏幕上(含副屏)") + "可见且清晰"
});
}
for (const p of cleanups) { try { fs.unlinkSync(p); } catch {} }
return { target: keyword, text: found.text, cx: found.cx, cy: found.cy };
}
function screenUser32(): string {
return [
"Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);' -Name RpaCvInput -Namespace RpaCv | Out-Null;",
"try { [RpaCv.RpaCvInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}"
].join("\n");
}
function coordinateScript(x: number, y: number, action: "hover" | "click" | "doubleClick" | "paste", value = ""): string {
const lines = [
screenUser32(),
"if (-not [RpaCv.RpaCvInput]::SetCursorPos(" + x + ", " + y + ")) { throw 'cursor_move_failed' }",
"Start-Sleep -Milliseconds 100"
];
if (action !== "hover") {
lines.push(
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
"Start-Sleep -Milliseconds 40",
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
);
}
if (action === "doubleClick") {
lines.push(
"Start-Sleep -Milliseconds 70",
"[RpaCv.RpaCvInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
"Start-Sleep -Milliseconds 40",
"[RpaCv.RpaCvInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)"
);
}
if (action === "paste") {
lines.push(
"Add-Type -AssemblyName System.Windows.Forms",
"Start-Sleep -Milliseconds 220",
"$saved = $null",
"try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}",
"try { [System.Windows.Forms.Clipboard]::SetText(" + JSON.stringify(value) + ") } catch { throw 'clipboard_failed' }",
"Start-Sleep -Milliseconds 80",
"[System.Windows.Forms.SendKeys]::SendWait('^a')",
"Start-Sleep -Milliseconds 60",
"[System.Windows.Forms.SendKeys]::SendWait('^v')",
"Start-Sleep -Milliseconds 280",
"try { if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) } else { [System.Windows.Forms.Clipboard]::Clear() } } catch {}"
);
}
lines.push("Write-Output (@{ ok = $true; x = " + x + "; y = " + y + " } | ConvertTo-Json -Compress)");
return lines.join("\n");
}
/** DIP → 物理像素(SetCursorPos 在 DPI 感知进程里用物理坐标); 无 Electron API 时按原值(缩放 100% 等价) */
async function toPhysical(x: number, y: number): Promise<{ x: number; y: number }> {
try {
const { screen } = await import("electron");
if (screen && typeof screen.dipToScreenPoint === "function") {
const p = screen.dipToScreenPoint({ x: Math.round(x), y: Math.round(y) });
return { x: p.x, y: p.y };
}
} catch {}
return { x: Math.round(x), y: Math.round(y) };
}
async function locateFor(args: Record<string, unknown>, node: { id: string; componentId: string }) {
const target = cvTarget(args, node.id);
// 可选搜索区域: 框选生成 {x,y,width,height,imagePath,scale}, 限定 OCR 查找范围
const region = parseRegionArg(args.searchRegion ?? args.region);
const located = await locateTextOnScreen(target, node.id, region);
const offsetX = Number(args.offsetX ?? args.offset_x ?? 0) || 0;
const offsetY = Number(args.offsetY ?? args.offset_y ?? 0) || 0;
return { located, x: Math.round(located.cx + offsetX), y: Math.round(located.cy + offsetY) };
}
const getTextGround: CapabilityHandler = async (ctx, node, args) => {
const target = cvTarget(args, node.id);
const result = await locateTextOnScreen(target, node.id);
ctx.log("CV_GetText found: " + result.text, "info", node.id);
return result.text;
};
const setTextGround: CapabilityHandler = async (ctx, node, args) => {
const value = String(args.value ?? args.text ?? "");
if (!value) throw new RuntimeError("validation_error", "CV 文本输入内容为空", { nodeId: node.id, componentId: node.componentId });
const { x, y } = await locateFor(args, node);
const phys = await toPhysical(x, y);
await powershell(coordinateScript(phys.x, phys.y, "paste", value), 30000);
ctx.log("CV_SetText input at " + phys.x + "," + phys.y, "info", node.id);
return true;
};
const mouseHoverGround: CapabilityHandler = async (ctx, node, args) => {
const { x, y } = await locateFor(args, node);
const phys = await toPhysical(x, y);
await powershell(coordinateScript(phys.x, phys.y, "hover"), 15000);
ctx.log("CV_MouseHover hover at " + phys.x + "," + phys.y, "info", node.id);
return true;
};
const mouseClickGround: CapabilityHandler = async (ctx, node, args) => {
const { x, y } = await locateFor(args, node);
const phys = await toPhysical(x, y);
const clickway = String(args.clickway ?? "0");
const action = clickway === "1" || /double/i.test(clickway) ? "doubleClick" as const : "click" as const;
await powershell(coordinateScript(phys.x, phys.y, action), 15000);
ctx.log("CV_MouseClick " + action + " at " + phys.x + "," + phys.y, "info", node.id);
return true;
};
register("CV_GetText", getTextGround);
register("CV_SetText", setTextGround);
register("CV_MouseHover", mouseHoverGround);
register("CV_MouseClick", mouseClickGround);
+111 -4
View File
@@ -1,14 +1,39 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
const powershellMock = vi.hoisted(() => vi.fn(async () => '{"clicked":true,"value":"cached"}'));
vi.mock("./desktop", () => ({
powershell: powershellMock,
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
}));
import { getHandler } from "../engine/registry"; import { getHandler } from "../engine/registry";
import { parseUiaSelector } from "./desktop-uia"; import { parseUiaSelector } from "./desktop-uia";
describe("desktop UIA ground capabilities", () => { describe("desktop UIA ground capabilities", () => {
it.each([ it.each([
"Element_ClickGround", "Element_ClickGround",
"Element_SetValueGround", "Element_SetValueGround",
"Element_ExistsGround", "Element_ExistsGround",
"Element_GetValueGround", "Element_GetValueGround",
"Element_WaitElementGround" "Element_WaitElementGround",
"Element_GetCheckGround",
"Element_SetCheckGround",
"Element_RegionGround",
"Element_GetSelectGround",
"Element_SetSelectGround",
"Element_GetAttributeGround",
"Element_SetAttributeGround",
"Element_GetButtonEnableGround",
"Element_ShotGround",
"Element_MouseToGround",
"Element_ScrollIntoViewGround",
"Element_ChildrenGround",
"Element_DesktopChildren",
"Element_RestoreWindowGround",
"Element_ActivateWindowGround",
"Element_MoveWindowGround"
])("registers %s", (id) => { ])("registers %s", (id) => {
expect(getHandler(id), id).toBeTruthy(); expect(getHandler(id), id).toBeTruthy();
}); });
@@ -33,8 +58,90 @@ describe("desktop UIA ground capabilities", () => {
expect(s.name).toBe("确定"); expect(s.name).toBe("确定");
}); });
it("rejects selector without any condition", () => { it("parses AI screen coordinate selector", () => {
expect(() => parseUiaSelector("window=只有窗口")).toThrow(); const s = parseUiaSelector("screen=100,200,320,80;window= MyApp;ai=1");
expect(s.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
expect(s.window).toBe("MyApp");
});
it("parses enriched AI relocation metadata", () => {
const selector = parseUiaSelector(
"screen=100,200,320,80;window=App;winClass=Chrome_WidgetWin_1;process=chrome;rel=20,30,320,80;name=login;id=loginButton;type=Button;class=Button;aiType=button;aiText=login;enriched=1;ai=1"
);
expect(selector.screen).toEqual({ x: 100, y: 200, w: 320, h: 80 });
expect(selector.rel).toEqual({ x: 20, y: 30, w: 320, h: 80 });
expect(selector.window).toBe("App");
expect(selector.winClass).toBe("Chrome_WidgetWin_1");
expect(selector.process).toBe("chrome");
expect(selector).toMatchObject({ name: "login", id: "loginButton", type: "Button", className: "Button", aiType: "button", aiText: "login", enriched: true, ai: true });
});
it("rejects invalid selector", () => {
expect(() => parseUiaSelector("")).toThrow(); expect(() => parseUiaSelector("")).toThrow();
expect(() => parseUiaSelector("screen=1,2,3")).toThrow();
}); });
}); });
describe("desktop UIA selector compatibility", () => {
beforeEach(() => {
powershellMock.mockClear();
powershellMock.mockImplementation(async () => '{"clicked":true,"value":"cached"}');
});
it("maps compound .NET ControlType names explicitly and preserves raw input", () => {
expect(parseUiaSelector("type=combobox")).toMatchObject({ type: "ComboBox", typeRaw: "combobox" });
expect(parseUiaSelector("type=listitem")).toMatchObject({ type: "ListItem", typeRaw: "listitem" });
expect(parseUiaSelector("type=datagrid")).toMatchObject({ type: "DataGrid", typeRaw: "datagrid" });
});
it("parses a stable ancestor path", () => {
const selector = parseUiaSelector("window=App;path=Pane>Edit[name=用户名;index=2]>Button[name=确定]");
expect(selector.path).toEqual([
{ type: "Pane" },
{ type: "Edit", name: "用户名", index: 2 },
{ type: "Button", name: "确定" }
]);
});
const context = { log: vi.fn() } as any;
const node = { id: "n1", componentId: "Element_ClickGround" } as any;
it("resolves screen selectors through UIA/window before coordinate fallback", async () => {
const args = {
selector: "screen=100,200,320,80;ai=1",
clickway: "1"
};
await expect(getHandler("Element_ClickGround")!(context, node, args, {} as any)).resolves.toEqual({ clicked: true, value: "cached" });
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
expect(script).toContain("RpaScreenInput");
expect(script).toContain("$x = [int]($rect.x + $rect.w / 2)");
expect(script).toContain("SetCursorPos($x, $y)");
expect(script).toContain("Resolve-RpaScreenRect");
expect(script).toContain("Find-RpaWindow");
});
it("dispatches screen setValue to coordinate paste", async () => {
await getHandler("Element_SetValueGround")!(context, node, {
selector: "screen=10,20,30,40",
value: "hello"
}, {} as any);
const script = String((powershellMock.mock.calls as unknown as Array<[string]>)[0]?.[0] ?? "");
expect(script).toContain("resolvedMethod = $rect.method");
expect(script).toContain("hello");
expect(script).toContain("Find-RpaWindow");
});
it("validates a picked screen rectangle for exists/wait", async () => {
powershellMock.mockImplementation(async () => '{"found":true,"method":"window_rel"}');
await expect(getHandler("Element_ExistsGround")!(context, node, {
selector: "screen=1,2,3,4"
}, {} as any)).resolves.toBe(true);
await expect(getHandler("Element_WaitElementGround")!(context, node, {
selector: "screen=1,2,3,4"
}, {} as any)).resolves.toBe(true);
expect(powershellMock).toHaveBeenCalled();
const script = String((powershellMock.mock.calls as unknown as Array<[string]>).at(-1)?.[0] ?? "");
expect(script).toContain("Resolve-RpaScreenRect");
});
});
+691 -39
View File
@@ -1,4 +1,6 @@
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import fs from "node:fs";
import path from "node:path";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop"; import { powershell, psString } from "./desktop";
@@ -6,11 +8,132 @@ interface UiaSelector {
window?: string; window?: string;
name?: string; name?: string;
id?: string; id?: string;
/** .NET ControlType 字段名(已映射,如 ComboBox/ListItem/DataGrid) */
type?: string; type?: string;
/** 用户输入的原始类型字符串 */
typeRaw?: string;
className?: string; className?: string;
screen?: { x: number; y: number; w: number; h: number };
/** Relative physical rect inside the top-level window. */
rel?: { x: number; y: number; w: number; h: number };
process?: string;
winClass?: string;
enriched?: boolean;
aiType?: string;
aiText?: string;
ai?: boolean;
/** 从窗口(或桌面根)到目标元素的祖先路径,逐段用稳定属性定位 */
path?: Array<{ type?: string; name?: string; id?: string; className?: string; index?: number }>;
index: number; index: number;
} }
/** ControlType 显式映射:type= 值 → .NET ControlType 字段名;复合名称不得用首字母大写推导 */
const UIA_CONTROL_TYPE_MAP: Record<string, string> = {
button: "Button",
calendar: "Calendar",
checkbox: "CheckBox",
combo: "ComboBox",
combobox: "ComboBox",
custom: "Custom",
datagrid: "DataGrid",
dataitem: "DataItem",
document: "Document",
edit: "Edit",
group: "Group",
header: "Header",
headeritem: "HeaderItem",
hyperlink: "Hyperlink",
image: "Image",
list: "List",
listbox: "List",
listitem: "ListItem",
menu: "Menu",
menubar: "MenuBar",
menuitem: "MenuItem",
pane: "Pane",
progressbar: "ProgressBar",
radio: "RadioButton",
radiobutton: "RadioButton",
scrollbar: "ScrollBar",
separator: "Separator",
slider: "Slider",
spinner: "Spinner",
splitbutton: "SplitButton",
statusbar: "StatusBar",
tab: "Tab",
tabitem: "TabItem",
table: "Table",
text: "Text",
thumb: "Thumb",
titlebar: "TitleBar",
toolbar: "ToolBar",
tooltip: "ToolTip",
tree: "Tree",
treeitem: "TreeItem",
window: "Window"
};
function normalizeUiaControlType(raw: string): string {
const key = raw.trim().toLowerCase().replace(/[\s_-]+/g, "");
return UIA_CONTROL_TYPE_MAP[key] ?? raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
}
/** 解析 path= 段:Pane>Edit[name=用户名;index=2],段间用 > 分隔,段内 [prop=value;...] */
function parseUiaSelectorPath(value: string): UiaSelector["path"] {
// 按括号外的 > 分段,避免 path=Edit[name=a>b] 这类属性值被错误拆开
const rawSegments: string[] = [];
let buffer = "";
let bracketDepth = 0;
for (const char of value) {
if (char === "[") bracketDepth += 1;
else if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
if (char === ">" && bracketDepth === 0) {
rawSegments.push(buffer);
buffer = "";
} else {
buffer += char;
}
}
rawSegments.push(buffer);
const segments = rawSegments.map(item => item.trim()).filter(Boolean);
if (!segments.length) return undefined;
const path: NonNullable<UiaSelector["path"]> = [];
for (const segment of segments) {
const match = segment.match(/^([A-Za-z][A-Za-z0-9_]*)?\s*(?:\[(.*)\])?$/);
if (!match) {
throw new RuntimeError("validation_error", `UIA 路径段无效:${segment}`, { recommendation: "使用 Type[name=xx;id=yy;class=zz;index=1] > 下一级" });
}
const entry: NonNullable<UiaSelector["path"]>[number] = {};
if (match[1]) entry.type = normalizeUiaControlType(match[1]);
const props = match[2];
if (props) {
for (const pair of props.split(/[;,,]/).map(item => item.trim()).filter(Boolean)) {
const eq = pair.indexOf("=");
if (eq <= 0) {
throw new RuntimeError("validation_error", `UIA 路径属性无效:${pair}`, { recommendation: "使用 name= / id= / class= / index=" });
}
const key = pair.slice(0, eq).trim().toLowerCase();
const val = pair.slice(eq + 1).trim();
if (key === "name" || key === "n") entry.name = val;
else if (key === "id" || key === "automationid") entry.id = val;
else if (key === "class" || key === "classname") entry.className = val;
else if (key === "type" || key === "controltype") entry.type = normalizeUiaControlType(val);
else if (key === "index") {
const n = Number(val);
if (!Number.isFinite(n) || n < 1) throw new RuntimeError("validation_error", `UIA 路径 index 无效:${val}`);
entry.index = Math.floor(n);
}
else throw new RuntimeError("validation_error", `UIA 路径属性不支持:${key}`);
}
}
if (!entry.type && !entry.name && !entry.id && !entry.className) {
throw new RuntimeError("validation_error", `UIA 路径段缺少定位条件:${segment}`, { recommendation: "每段至少提供 type/name/id/class 之一" });
}
path.push(entry);
}
return path;
}
export function parseUiaSelector(raw: unknown): UiaSelector { export function parseUiaSelector(raw: unknown): UiaSelector {
let text = ""; let text = "";
if (raw && typeof raw === "object") { if (raw && typeof raw === "object") {
@@ -30,7 +153,22 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
} }
if (text.startsWith("uia:")) text = text.slice(4); if (text.startsWith("uia:")) text = text.slice(4);
const selector: UiaSelector = { index: 1 }; const selector: UiaSelector = { index: 1 };
for (const part of text.split(";")) { // path 段内的 [name=a;b] 不应被顶层 ; 拆开
const selectorParts: string[] = [];
let partBuffer = "";
let selectorBracketDepth = 0;
for (const char of text) {
if (char === "[") selectorBracketDepth += 1;
else if (char === "]") selectorBracketDepth = Math.max(0, selectorBracketDepth - 1);
if (char === ";" && selectorBracketDepth === 0) {
selectorParts.push(partBuffer);
partBuffer = "";
} else {
partBuffer += char;
}
}
selectorParts.push(partBuffer);
for (const part of selectorParts) {
const eq = part.indexOf("="); const eq = part.indexOf("=");
if (eq <= 0) continue; if (eq <= 0) continue;
const key = part.slice(0, eq).trim().toLowerCase(); const key = part.slice(0, eq).trim().toLowerCase();
@@ -39,14 +177,41 @@ export function parseUiaSelector(raw: unknown): UiaSelector {
if (key === "window" || key === "win") selector.window = value; if (key === "window" || key === "win") selector.window = value;
else if (key === "name" || key === "n") selector.name = value; else if (key === "name" || key === "n") selector.name = value;
else if (key === "id" || key === "automationid") selector.id = value; else if (key === "id" || key === "automationid") selector.id = value;
else if (key === "type" || key === "controltype") selector.type = value.charAt(0).toUpperCase() + value.slice(1).toLowerCase(); else if (key === "type" || key === "controltype") {
selector.typeRaw = value;
selector.type = normalizeUiaControlType(value);
}
else if (key === "class" || key === "classname") selector.className = value; else if (key === "class" || key === "classname") selector.className = value;
else if (key === "screen") {
const nums = value.split(/[,,]/).map(item => Number(item.trim()));
if (nums.length !== 4 || nums.some(n => !Number.isFinite(n))) {
throw new RuntimeError("validation_error", "screen 坐标格式无效:" + value, { recommendation: "使用 screen=x,y,width,height" });
}
selector.screen = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
}
else if (key === "rel") {
const nums = value.split(/[,،]/).map(item => Number(item.trim()));
if (nums.length !== 4 || nums.some(n => !Number.isFinite(n))) {
throw new RuntimeError("validation_error", "rel coordinate invalid: " + value, { recommendation: "rel=x,y,width,height" });
}
selector.rel = { x: nums[0], y: nums[1], w: nums[2], h: nums[3] };
}
else if (key === "process") selector.process = value;
else if (key === "winclass") selector.winClass = value;
else if (key === "enriched") selector.enriched = value === "1" || value.toLowerCase() === "true";
else if (key === "aitype") selector.aiType = value;
else if (key === "aitext") selector.aiText = value;
else if (key === "ai") selector.ai = value === "1" || value.toLowerCase() === "true";
else if (key === "index") { else if (key === "index") {
const n = Number(value); const n = Number(value);
if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n); if (Number.isFinite(n) && n >= 1) selector.index = Math.floor(n);
} }
else if (key === "path" || key === "ancestors" || key === "ancestorpath") {
selector.path = parseUiaSelectorPath(value);
} }
if (!selector.name && !selector.id && !selector.className && !selector.type) { }
const hasWindowAnchor = Boolean(selector.window || selector.winClass || selector.process);
if (!selector.name && !selector.id && !selector.className && !selector.type && !selector.screen && !selector.path && !(selector.rel && hasWindowAnchor)) {
throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" }); throw new RuntimeError("validation_error", `桌面元素选择器缺少定位条件:${text}`, { recommendation: "至少提供 name= / id= / type= 之一" });
} }
return selector; return selector;
@@ -58,53 +223,143 @@ const UIA_USING = [
].join("\n"); ].join("\n");
const FIND_HELPER = ` const FIND_HELPER = `
function Find-RpaWindow([string]$winName) { function Find-RpaWindow([string]$winName, [string]$winClass, [string]$processName) {
# 只读查找:绝不移动/还原/缩放窗口;还原、激活、移动是独立显式动作(Restore/Activate/Move-RpaWindow)
$root = [System.Windows.Automation.AutomationElement]::RootElement $root = [System.Windows.Automation.AutomationElement]::RootElement
if (-not $winName) { return $root } if (-not $winName -and -not $winClass -and -not $processName) { return $root }
if ($winName) {
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName) $cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond) $win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if ($win) { return $win } if ($win) { return $win }
}
Add-RpaUser32 Add-RpaUser32
$script:hitHwnd = [IntPtr]::Zero $script:hitHwnd = [IntPtr]::Zero
$cb = [WinRpa.RpaUser32Click+EnumProc]{ param($h, $l) $cb = [Win32Rpa.RpaUser32Click+EnumProc]{ param($h, $l)
$sb = New-Object System.Text.StringBuilder 256 if (-not [Win32Rpa.RpaUser32Click]::IsWindowVisible($h)) { return $true }
[WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256) | Out-Null $titleSb = New-Object System.Text.StringBuilder 512
if ($sb.ToString() -eq $winName) { $script:hitHwnd = $h; return $false } [void][Win32Rpa.RpaUser32Click]::GetWindowTextW($h, $titleSb, 512)
return $true $title = $titleSb.ToString()
if ($winName -and $title -notlike "*$winName*") { return $true }
$classSb = New-Object System.Text.StringBuilder 256
[void][Win32Rpa.RpaUser32Click]::GetClassNameW($h, $classSb, 256)
$className = $classSb.ToString()
if ($winClass -and $className -ne $winClass) { return $true }
if ($processName) {
$windowPid = [uint32]0
[void][Win32Rpa.RpaUser32Click]::GetWindowThreadProcessId($h, [ref]$windowPid)
try { $procName = (Get-Process -Id $windowPid -ErrorAction Stop).ProcessName } catch { return $true }
if (-not $procName.Equals($processName, [StringComparison]::OrdinalIgnoreCase)) { return $true }
} }
[WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null $script:hitHwnd = $h
return $false
}
[void][Win32Rpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero)
if ($script:hitHwnd -ne [IntPtr]::Zero) { if ($script:hitHwnd -ne [IntPtr]::Zero) {
[WinRpa.RpaUser32Click]::ShowWindow($script:hitHwnd, 9) | Out-Null try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {}
Start-Sleep -Milliseconds 500
$sw2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
$sh2 = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
$w2 = [Math]::Min(1400, $sw2 - 120)
$h2 = [Math]::Min(820, $sh2 - 120)
[WinRpa.RpaUser32Click]::SetWindowPos($script:hitHwnd, [IntPtr]::Zero, 60, 60, $w2, $h2, 0x0040) | Out-Null
Start-Sleep -Milliseconds 600
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
if ($win) { return $win }
} }
throw "window_not_found:$winName" $missing = (@($winName, $winClass, $processName) | Where-Object { $_ }) -join ","
throw "window_not_found:$missing"
} }
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index) { function Restore-RpaWindow($win) {
# 显式动作:还原窗口(最小化/最大化状态恢复),不用于查询
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
[void][WinRpa.RpaUser32Click]::ShowWindow($hwnd, 9)
Start-Sleep -Milliseconds 300
return $hwnd
}
function Activate-RpaWindow($win) {
# 显式动作:把窗口带到前台
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
$fgHwnd = [WinRpa.RpaUser32Click]::GetForegroundWindow()
if ($fgHwnd -ne $hwnd) {
$fgPid = [uint32]0
$fgThread = [WinRpa.RpaUser32Click]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
$thisThread = [WinRpa.RpaUser32Click]::GetCurrentThreadId()
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $true)
[void][WinRpa.RpaUser32Click]::SetForegroundWindow($hwnd)
[void][WinRpa.RpaUser32Click]::AttachThreadInput($thisThread, $fgThread, $false)
}
Start-Sleep -Milliseconds 200
return $hwnd
}
function Move-RpaWindow($win, [int]$x, [int]$y, [int]$w, [int]$h) {
# 显式动作:移动/缩放窗口;w/h<=0 时使用安全默认尺寸
Add-RpaUser32
$hwnd = [IntPtr]$win.Current.NativeWindowHandle
if ($hwnd -eq [IntPtr]::Zero) { throw "window_no_hwnd" }
$sw = [WinRpa.RpaUser32Click]::GetSystemMetrics(0)
$sh = [WinRpa.RpaUser32Click]::GetSystemMetrics(1)
if ($w -le 0) { $w = [Math]::Min(1400, $sw - 120) }
if ($h -le 0) { $h = [Math]::Min(820, $sh - 120) }
[void][WinRpa.RpaUser32Click]::SetWindowPos($hwnd, [IntPtr]::Zero, $x, $y, $w, $h, 0x0040)
Start-Sleep -Milliseconds 300
return $hwnd
}
function Find-RpaElement($parent, [string]$name, [string]$id, [string]$type, [string]$className, [int]$index, [switch]$directChild) {
$conds = @() $conds = @()
if ($name) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) } if ($name) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $name) }
if ($id) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $id) } if ($id) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::AutomationIdProperty, $id) }
if ($className) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty, $className) } if ($className) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty, $className) }
if ($type) { if ($type) {
try { $field = [System.Windows.Automation.ControlType].GetField($type); if ($field) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $field.GetValue($null)) } } catch {} # 不区分大小写匹配 .NET ControlType 字段,兼容 ComboBox/ListItem/DataGrid 等复合名称
if ($ct) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ct) } try {
$ctField = [System.Windows.Automation.ControlType].GetFields([System.Reflection.BindingFlags]"Public,Static") | Where-Object { $_.Name -ieq $type } | Select-Object -First 1
if ($ctField) { $conds += New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty, $ctField.GetValue($null)) }
} catch {}
} }
if ($conds.Count -eq 0) { throw "no_condition" } if ($conds.Count -eq 0) { throw "no_condition" }
$final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) } $final = if ($conds.Count -eq 1) { $conds[0] } else { New-Object System.Windows.Automation.AndCondition($conds) }
$all = $parent.FindAll([System.Windows.Automation.TreeScope]::Descendants, $final) $scope = if ($directChild) { [System.Windows.Automation.TreeScope]::Children } else { [System.Windows.Automation.TreeScope]::Descendants }
$all = $parent.FindAll($scope, $final)
if ($all.Count -eq 0) { return $null } if ($all.Count -eq 0) { return $null }
$pick = [Math]::Min([Math]::Max($index, 1), $all.Count) $pick = [Math]::Min([Math]::Max($index, 1), $all.Count)
return $all[$pick - 1] return $all[$pick - 1]
} }
function Find-RpaElementByPath($parent, [string]$pathJsonBase64) {
# 逐段走直接子级,用稳定属性(id/type/class 优先)解析窗口→目标祖先路径
$segments = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($pathJsonBase64)) | ConvertFrom-Json
$cur = $parent
foreach ($seg in @($segments)) {
$ix = 1
try { if ($seg.index) { $ix = [int]$seg.index } } catch {}
$next = Find-RpaElement $cur $seg.name $seg.id $seg.type $seg.className $ix -directChild
if ($null -eq $next) { return $null }
$cur = $next
}
return $cur
}
function Add-RpaUser32 { function Add-RpaUser32 {
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int cx, int cy, uint flags); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp); public delegate bool EnumProc(IntPtr hWnd, IntPtr lp); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder sb, int max);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int X, int Y, int cx, int cy, uint flags); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll")] public static extern int GetSystemMetrics(int nIndex); [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp); public delegate bool EnumProc(IntPtr hWnd, IntPtr lp); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowTextW(IntPtr hWnd, System.Text.StringBuilder sb, int max); [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassNameW(IntPtr hWnd, System.Text.StringBuilder sb, int max); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);' -Name RpaUser32Click -Namespace Win32Rpa | Out-Null
}
`;
const RESOLVE_HELPER = `
function Resolve-RpaScreenRect($s) {
$hasStable = [bool]($s.name -or $s.id -or $s.type -or $s.className)
$hasWindowAnchor = [bool]($s.window -or $s.winClass -or $s.process)
if ($s.enriched -and $hasStable) {
try {
$parent = Find-RpaWindow ([string]$s.window) ([string]$s.winClass) ([string]$s.process)
$el = Find-RpaElement $parent ([string]$s.name) ([string]$s.id) ([string]$s.type) ([string]$s.className) ([int]1)
if ($el) {
$r = $el.Current.BoundingRectangle
if ($r.Width -gt 0 -and $r.Height -gt 0) { return @{ x=[int]$r.X; y=[int]$r.Y; w=[int]$r.Width; h=[int]$r.Height; method='uia' } }
}
} catch {}
}
if ($s.rel -and $hasWindowAnchor) {
try {
$parent = Find-RpaWindow ([string]$s.window) ([string]$s.winClass) ([string]$s.process)
$r = $parent.Current.BoundingRectangle
return @{ x=[int]($r.X + $s.rel.x); y=[int]($r.Y + $s.rel.y); w=[int]$s.rel.w; h=[int]$s.rel.h; method='window_rel' }
} catch {}
}
if ($s.screen) { return @{ x=[int]$s.screen.x; y=[int]$s.screen.y; w=[int]$s.screen.w; h=[int]$s.screen.h; method='screen_fallback' } }
throw "selector_rect_not_found"
} }
`; `;
@@ -119,6 +374,118 @@ function target(args: Record<string, unknown>, node: { id: string; componentId:
return { selector: parseUiaSelector(raw), args, node }; return { selector: parseUiaSelector(raw), args, node };
} }
/** 生成“解析窗口 + 解析元素(路径优先,其次平铺属性)”的 PowerShell 语句,结果写入 $el */
function elementResolveLines(s: UiaSelector): string {
const parentLine = "$parent = Find-RpaWindow " + psString(s.window ?? "") + " " + psString(s.winClass ?? "") + " " + psString(s.process ?? "");
if (s.path?.length) {
const pathB64 = Buffer.from(JSON.stringify(s.path), "utf8").toString("base64");
return parentLine + "\n$el = Find-RpaElementByPath $parent '" + pathB64 + "'";
}
return parentLine
+ "\n$el = Find-RpaElement $parent " + psString(s.name ?? "")
+ " " + psString(s.id ?? "")
+ " " + psString(s.type ?? "")
+ " " + psString(s.className ?? "")
+ " " + s.index;
}
const SCREEN_USER32 = "Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y); [DllImport(\"user32.dll\")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);' -Name RpaScreenInput -Namespace RpaScreen | Out-Null; try { [RpaScreen.RpaScreenInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) | Out-Null } catch {}";
function resolveScreenPreamble(t: UiaTarget): string {
const s = t.selector;
const screen = s.screen ? `@{x=${s.screen.x};y=${s.screen.y};w=${s.screen.w};h=${s.screen.h}}` : "$null";
const rel = s.rel ? `@{x=${s.rel.x};y=${s.rel.y};w=${s.rel.w};h=${s.rel.h}}` : "$null";
return `${UIA_USING}
${FIND_HELPER}
${RESOLVE_HELPER}
$s = @{ enriched=${s.enriched ? "$true" : "$false"}; window=${JSON.stringify(s.window ?? "")}; winClass=${JSON.stringify(s.winClass ?? "")}; process=${JSON.stringify(s.process ?? "")}; name=${JSON.stringify(s.name ?? "")}; id=${JSON.stringify(s.id ?? "")}; type=${JSON.stringify(s.type ?? "")}; className=${JSON.stringify(s.className ?? "")}; screen=${screen}; rel=${rel} }
$rect = Resolve-RpaScreenRect $s
$x = [int]($rect.x + $rect.w / 2)
$y = [int]($rect.y + $rect.h / 2)`;
}
function clickScreenScript(t: UiaTarget): string {
const clickway = String(t.args.clickway ?? "0");
const doubleClick = clickway === "1" || /double/i.test(clickway);
return `${resolveScreenPreamble(t)}
${SCREEN_USER32}
if (-not [RpaScreen.RpaScreenInput]::SetCursorPos($x, $y)) { throw "cursor_move_failed" }
Start-Sleep -Milliseconds 100
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
if (${doubleClick}) {
Start-Sleep -Milliseconds 70
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
}
Write-Output (@{ clicked = $true; method = $rect.method; resolvedMethod = $rect.method; x = $x; y = $y } | ConvertTo-Json -Compress)`;
}
function setValueScreenScript(t: UiaTarget, value: string): string {
return `${resolveScreenPreamble(t)}
${SCREEN_USER32}
Add-Type -AssemblyName System.Windows.Forms
[RpaScreen.RpaScreenInput]::SetCursorPos($x, $y) | Out-Null
Start-Sleep -Milliseconds 120
[RpaScreen.RpaScreenInput]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 40
[RpaScreen.RpaScreenInput]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)
Start-Sleep -Milliseconds 220
$saved = $null
try { $saved = [System.Windows.Forms.Clipboard]::GetText() } catch {}
try { [System.Windows.Forms.Clipboard]::SetText(${psString(value)}) } catch { throw "clipboard_failed" }
Start-Sleep -Milliseconds 80
[System.Windows.Forms.SendKeys]::SendWait('^a')
Start-Sleep -Milliseconds 60
[System.Windows.Forms.SendKeys]::SendWait('^v')
Start-Sleep -Milliseconds 280
try {
if ($saved) { [System.Windows.Forms.Clipboard]::SetText($saved) } else { [System.Windows.Forms.Clipboard]::Clear() }
} catch {}
Write-Output (@{ set = $true; method = $rect.method; resolvedMethod = $rect.method; x = $x; y = $y } | ConvertTo-Json -Compress)`;
}
function getValueScreenScript(t: UiaTarget): string {
return `${resolveScreenPreamble(t)}
Add-Type -AssemblyName WindowsBase
$pt = New-Object System.Windows.Point($x, $y)
$el = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
if ($null -eq $el) { Write-Output 'JSON_NOT_FOUND'; exit 0 }
$value = $el.Current.Name
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
Write-Output (@{ value = $value; name = $el.Current.Name; method = $rect.method } | ConvertTo-Json -Compress)`;
}
function screenExistsScript(t: UiaTarget): string {
return `${resolveScreenPreamble(t)}
Add-Type -AssemblyName WindowsBase
$found = $true
if ($rect.method -ne 'uia') {
try {
$pt = New-Object System.Windows.Point($x, $y)
$hit = [System.Windows.Automation.AutomationElement]::FromPoint($pt)
if ($null -eq $hit) { $found = $false }
else {
$hr = $hit.Current.BoundingRectangle
$inside = ($hr.Right -ge $rect.x) -and ($hr.Left -le ($rect.x + $rect.w)) -and ($hr.Bottom -ge $rect.y) -and ($hr.Top -le ($rect.y + $rect.h))
if (-not $inside) { $found = $false }
}
} catch { $found = $false }
}
Write-Output (@{ found = $found; method = $rect.method; x = $rect.x; y = $rect.y; w = $rect.w; h = $rect.h } | ConvertTo-Json -Compress)`;
}
async function screenElementExists(t: UiaTarget): Promise<boolean> {
try {
const result = await runScript(screenExistsScript(t), t);
return result?.found === true;
} catch {
return false;
}
}
function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string { function findScript(t: UiaTarget, timeoutMs: number, gapMs: number): string {
const s = t.selector; const s = t.selector;
return `${UIA_USING} return `${UIA_USING}
@@ -127,8 +494,8 @@ $deadline = [DateTime]::UtcNow.AddMilliseconds(${Math.max(0, Math.round(timeoutM
$found = $null $found = $null
while ($null -eq $found) { while ($null -eq $found) {
try { try {
$parent = Find-RpaWindow ${psString(s.window ?? "")} ${elementResolveLines(s)}
$found = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index} $found = $el
} catch { } catch {
$err = $_.Exception.Message $err = $_.Exception.Message
if ($err -like "window_not_found:*") { $found = $null } if ($err -like "window_not_found:*") { $found = $null }
@@ -162,8 +529,7 @@ function clickScript(t: UiaTarget): string {
${FIND_HELPER} ${FIND_HELPER}
$el = $null $el = $null
try { try {
$parent = Find-RpaWindow ${psString(s.window ?? "")} ${elementResolveLines(s)}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null } } catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$rect = $el.Current.BoundingRectangle $rect = $el.Current.BoundingRectangle
@@ -258,10 +624,7 @@ export function setValueScript(t: UiaTarget, value: string): string {
const s = t.selector; const s = t.selector;
return `${UIA_USING} return `${UIA_USING}
${FIND_HELPER} ${FIND_HELPER}
try { ${elementResolveLines(s)}
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = ${psString(value)} $value = ${psString(value)}
$method = "value_pattern" $method = "value_pattern"
@@ -295,10 +658,7 @@ function getValueScript(t: UiaTarget): string {
const s = t.selector; const s = t.selector;
return `${UIA_USING} return `${UIA_USING}
${FIND_HELPER} ${FIND_HELPER}
try { ${elementResolveLines(s)}
$parent = Find-RpaWindow ${psString(s.window ?? "")}
$el = Find-RpaElement $parent ${psString(s.name ?? "")} ${psString(s.id ?? "")} ${psString(s.type ?? "")} ${psString(s.className ?? "")} ${s.index}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 } if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
$value = $el.Current.Name $value = $el.Current.Name
try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {} try { $vp = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern); $value = $vp.Value } catch {}
@@ -349,29 +709,47 @@ function requireFound(result: Record<string, unknown> | null, t: UiaTarget, acti
const clickGround: CapabilityHandler = async (_ctx, node, args) => { const clickGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node); const t = target(args, node);
if (t.selector.screen) return runScript(clickScreenScript(t), t);
return runWithImplicitWait(t, "click", clickScript(t), runScript, args); return runWithImplicitWait(t, "click", clickScript(t), runScript, args);
}; };
const setValueGround: CapabilityHandler = async (_ctx, node, args) => { const setValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node); const t = target(args, node);
const value = String(args.value ?? args.text ?? ""); const value = String(args.value ?? args.text ?? "");
if (t.selector.screen) return runScript(setValueScreenScript(t, value), t);
return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args); return runWithImplicitWait(t, "setValue", setValueScript(t, value), runScript, args);
}; };
const existsGround: CapabilityHandler = async (_ctx, node, args) => { const existsGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node); const t = target(args, node);
if (t.selector.screen) return await screenElementExists(t);
const found = await findElement(t, 1000, 150); const found = await findElement(t, 1000, 150);
return found !== null; return found !== null;
}; };
const getValueGround: CapabilityHandler = async (_ctx, node, args) => { const getValueGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node); const t = target(args, node);
if (t.selector.screen) {
const result = await runScript(getValueScreenScript(t), t);
return requireFound(result, t, "getValue").value ?? "";
}
const result = await runWithImplicitWait(t, "getValue", getValueScript(t), runScript, args); const result = await runWithImplicitWait(t, "getValue", getValueScript(t), runScript, args);
return result.value ?? ""; return result.value ?? "";
}; };
const waitElementGround: CapabilityHandler = async (_ctx, node, args) => { const waitElementGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node); const t = target(args, node);
if (t.selector.screen) {
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
const deadline = Date.now() + timeout;
do {
if (await screenElementExists(t)) return true;
if (Date.now() >= deadline) break;
await new Promise(resolve => setTimeout(resolve, gap));
} while (Date.now() < deadline);
return false;
}
const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000; const timeout = Number(args.timeout ?? args.timeoutMs ?? 5000) || 5000;
const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200; const gap = Number(args.time_gap ?? args.gapMs ?? 200) || 200;
const found = await findElement(t, timeout, gap); const found = await findElement(t, timeout, gap);
@@ -382,6 +760,39 @@ register("Element_ClickGround", clickGround);
register("Element_SetValueGround", setValueGround); register("Element_SetValueGround", setValueGround);
register("Element_ExistsGround", existsGround); register("Element_ExistsGround", existsGround);
register("Element_GetValueGround", getValueGround); register("Element_GetValueGround", getValueGround);
const restoreWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const lines = ["$hwnd = Restore-RpaWindow $el"];
if ([args.x, args.y, args.width, args.height].some((value) => value !== undefined && value !== null && String(value).trim() !== "")) {
const x = Math.round(Number(args.x ?? 60)) || 0;
const y = Math.round(Number(args.y ?? 60)) || 0;
const width = Math.round(Number(args.width ?? 0)) || 0;
const height = Math.round(Number(args.height ?? 0)) || 0;
lines.push("$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height);
}
return runWithImplicitWait(t, "restoreWindow", propertyValue([...lines, "Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
};
const activateWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "activateWindow", propertyValue([
"$hwnd = Activate-RpaWindow $el",
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const moveWindowGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const x = Math.round(Number(args.x ?? 60)) || 0;
const y = Math.round(Number(args.y ?? 60)) || 0;
const width = Math.round(Number(args.width ?? 0)) || 0;
const height = Math.round(Number(args.height ?? 0)) || 0;
return runWithImplicitWait(t, "moveWindow", propertyValue([
"$hwnd = Move-RpaWindow $el " + x + " " + y + " " + width + " " + height,
"Write-Output (@{ hwnd = [string]$hwnd; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
register("Element_WaitElementGround", waitElementGround); register("Element_WaitElementGround", waitElementGround);
@@ -393,3 +804,244 @@ register("Element_WaitElementGround", waitElementGround);
function outputPath(args: Record<string, unknown>, extension = "png"): string {
const raw = args.filePath ?? args.folder ?? args.path;
const folder = path.resolve(String(raw && String(raw).trim() ? raw : process.cwd()));
const rawName = String(args.filename ?? args.fileName ?? args.name ?? `element-${Date.now()}.${extension}`)
.replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 10));
const name = /\.[A-Za-z0-9]{2,8}$/.test(rawName) ? rawName : `${rawName}.${extension}`;
return path.join(folder, name);
}
function propertyValue(scriptLines: string[], t: UiaTarget): string {
const s = t.selector;
return `${UIA_USING}
${FIND_HELPER}
$el = $null
try {
${elementResolveLines(s)}
} catch { $el = $null }
if ($null -eq $el) { Write-Output "JSON_NOT_FOUND"; exit 0 }
${scriptLines.join("\n")}`;
}
const getCheckGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getCheck", propertyValue([
"$checked = $false",
"try { $checked = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
"Write-Output (@{ value = [bool]$checked; name = $el.Current.Name; enabled = [bool]$el.Current.IsEnabled } | ConvertTo-Json -Compress)"
], t), runScript, args);
return result.value === true;
};
const setCheckGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const desired = ["1", "true", "yes", "on"].includes(String(args.isChecked ?? "1").toLowerCase());
return runWithImplicitWait(t, "setCheck", propertyValue([
`$desired = ${desired ? "$true" : "$false"}`,
"$current = $false; $hasToggle = $false",
"try { $pattern = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern); $current = $pattern.Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On; $hasToggle = $true } catch {}",
"$method = if ($hasToggle) { 'toggle_pattern' } else { 'coordinate_click' }",
"if (-not $hasToggle -or $current -ne $desired) {",
" if ($hasToggle) { $pattern.Toggle() | Out-Null }",
" else {",
" $rect = $el.Current.BoundingRectangle; Add-RpaUser32",
" [Win32Rpa.RpaUser32Click]::SetCursorPos([int]($rect.X + $rect.Width / 2), [int]($rect.Y + $rect.Height / 2)) | Out-Null",
" Start-Sleep -Milliseconds 100; [Win32Rpa.RpaUser32Click]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero)",
" Start-Sleep -Milliseconds 50; [Win32Rpa.RpaUser32Click]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)",
" }",
"}",
"$after = $false; try { $after = $el.GetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern).Current.ToggleState -eq [System.Windows.Automation.ToggleState]::On } catch {}",
"Write-Output (@{ set = ($after -eq $desired); checked = [bool]$after; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const regionGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const relative = String(args.position ?? "3") === "2";
const result = await runWithImplicitWait(t, "getRegion", propertyValue([
"$rect = $el.Current.BoundingRectangle; $ox = 0; $oy = 0",
relative ? [
"try {",
" Add-RpaUser32",
' Add-Type -MemberDefinition \'[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }\' -Name RpaWindowRect -Namespace Win32RpaRect | Out-Null',
" $walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $ancestor = $el",
" while ($null -ne $ancestor) {",
" $hwnd = $ancestor.Current.NativeWindowHandle",
" if ($hwnd -ne 0) { $wr = New-Object Win32RpaRect.RpaWindowRect+RECT; [void][Win32RpaRect.RpaWindowRect]::GetWindowRect([IntPtr]$hwnd, [ref]$wr); $ox = $wr.Left; $oy = $wr.Top; break }",
" $ancestor = $walker.GetParent($ancestor)",
" }",
"} catch {}",
].join("\n") : "",
"Write-Output (@{ x = [int]($rect.X - $ox); y = [int]($rect.Y - $oy); width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)"
], t), runScript, args);
return result;
};
function getSelectScript(t: UiaTarget): string {
const byText = String(t.args.choose ?? "1") !== "2";
return propertyValue([
"$result = @(); $method = 'selection_pattern'",
"try { $sel = $el.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern); foreach ($item in $sel.Current.GetSelection()) { $value = $item.Current.Name; if (-not " + (byText ? "$true" : "$false") + ") { $value = $item.Current.AutomationId }; $result += @{ text = $item.Current.Name; value = $item.Current.AutomationId; selected = $true } } } catch {}",
"if ($result.Count -eq 0) { try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }; $result += @{ text = [string]$value; value = [string]$value; selected = $true }; $method = 'value_pattern' }",
"Write-Output (@{ result = $result; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress -Depth 3)"
], t);
}
const getSelectGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getSelect", getSelectScript(t), runScript, args);
const items = Array.isArray(result.result) ? result.result : [];
return items.length === 1 ? items[0] : items;
};
function setSelectScript(t: UiaTarget, values: string[]): string {
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
const choose = String(t.args.choose ?? "1");
return propertyValue([
`$desired = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json`,
"$items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' })",
"if ($items.Count -eq 0) { try { $el.Expand() } catch {}; Start-Sleep -Milliseconds 250; $items = @($el.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition) | Where-Object { $_.Current.ControlType.ProgrammaticName -match 'ListItem|ComboBoxItem' }) }",
"$targets = @(); $method = 'selection_item_pattern'",
"foreach ($wanted in @($desired)) {",
` if ('${choose}' -eq '0') { $ix = [int]$wanted - 1; if ($ix -ge 0 -and $ix -lt $items.Count) { $targets += $items[$ix] } }`,
` elseif ('${choose}' -eq '2') { $targets += @($items | Where-Object { $_.Current.AutomationId -eq [string]$wanted }) }`,
" else { $targets += @($items | Where-Object { $_.Current.Name -eq [string]$wanted }) }",
"}",
"$ok = $targets.Count -gt 0",
"foreach ($item in $targets) { try { $item.GetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern).Select() } catch { try { $item.Invoke() } catch { $ok = $false } } }",
"try { $el.Collapse() } catch {}",
"Write-Output (@{ set = [bool]$ok; matched = $targets.Count; available = $items.Count; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
const setSelectGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const raw = args.itemsToSelect ?? args.value ?? args.text;
let values: string[] = [];
if (Array.isArray(raw)) values = raw.map((value) => String(value));
else if (raw !== undefined && raw !== null && String(raw).trim() !== "") {
try { const parsed = JSON.parse(String(raw)); values = Array.isArray(parsed) ? parsed.map((value) => String(value)) : [String(parsed)]; }
catch { values = [String(raw)]; }
}
if (!values.length) throw new RuntimeError("validation_error", "下拉框选择项为空", { nodeId: node.id, componentId: node.componentId });
return runWithImplicitWait(t, "setSelect", setSelectScript(t, values), runScript, args);
};
function attrScript(t: UiaTarget, attrName: string, attrValue = ""): string {
const name = attrName.toLowerCase();
if (name === "value" || name === "text") {
return propertyValue([
"try { $value = $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).Value } catch { $value = $el.Current.Name }",
"Write-Output (@{ value = [string]$value; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
if (!attrValue) {
return propertyValue([
"$p = @{ name = $el.Current.Name; id = $el.Current.AutomationId; class = $el.Current.ClassName; type = $el.Current.ControlType.ProgrammaticName; enabled = $el.Current.IsEnabled; help = $el.Current.HelpText; value = $el.Current.Name }",
`$key = ${psString(name || "name")}`,
"$result = if ($p.Contains($key)) { $p[$key] } else { $p.name }",
"Write-Output (@{ value = [string]$result; properties = $p; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
if (name !== "value" && name !== "text") throw new RuntimeError("validation_error", `桌面 UIA 不支持设置属性:${attrName};可设置 value/text`, { nodeId: t.node.id, componentId: t.node.componentId });
return propertyValue([
"$ok = $false; $method = 'value_pattern'",
`try { $el.GetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern).SetValue(${psString(attrValue)}); $ok = $true } catch {}`,
"if (-not $ok) { $method = 'keyboard'; Add-Type -AssemblyName System.Windows.Forms; $el.SetFocus() | Out-Null; Start-Sleep -Milliseconds 100; [System.Windows.Forms.SendKeys]::SendWait('^a'); [System.Windows.Forms.SendKeys]::SendWait('^v'); Start-Sleep -Milliseconds 100; $ok = $true }",
"Write-Output (@{ set = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t);
}
const getAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const attrName = String(args.attrName ?? args.attribute ?? "name");
const result = await runWithImplicitWait(t, "getAttribute", attrScript(t, attrName), runScript, args);
return result.value ?? "";
};
const setAttributeGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const attrName = String(args.attrName ?? args.attribute ?? "value");
const attrValue = String(args.attrValue ?? args.value ?? "");
return runWithImplicitWait(t, "setAttribute", attrScript(t, attrName, attrValue), runScript, args);
};
const getButtonEnableGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const result = await runWithImplicitWait(t, "getButtonEnable", propertyValue(["Write-Output (@{ value = [bool]$el.Current.IsEnabled; name = $el.Current.Name } | ConvertTo-Json -Compress)"], t), runScript, args);
return result.value === true;
};
const shotGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
const output = outputPath(args);
fs.mkdirSync(path.dirname(output), { recursive: true });
const result = await runWithImplicitWait(t, "shot", propertyValue([
"$rect = $el.Current.BoundingRectangle; if ($rect.Width -le 0 -or $rect.Height -le 0) { throw 'element_not_visible' }",
"Add-Type -AssemblyName System.Drawing | Out-Null",
"$bitmap = New-Object System.Drawing.Bitmap([int]$rect.Width, [int]$rect.Height)",
"$graphics = [System.Drawing.Graphics]::FromImage($bitmap)",
"$graphics.CopyFromScreen([int]$rect.X, [int]$rect.Y, 0, 0, $bitmap.Size); $graphics.Dispose()",
`$bitmap.Save(${psString(output)}, [System.Drawing.Imaging.ImageFormat]::Png); $bitmap.Dispose()`,
`Write-Output (@{ value = ${psString(output)}; x = [int]$rect.X; y = [int]$rect.Y; width = [int]$rect.Width; height = [int]$rect.Height } | ConvertTo-Json -Compress)`
], t), runScript, args);
return result.value ?? output;
};
const mouseToGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "mouseTo", propertyValue([
"$rect = $el.Current.BoundingRectangle; Add-RpaUser32",
"$x = [int]($rect.X + $rect.Width / 2); $y = [int]($rect.Y + $rect.Height / 2)",
"[Win32Rpa.RpaUser32Click]::SetCursorPos($x, $y) | Out-Null",
"Write-Output (@{ x = $x; y = $y; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
const scrollIntoViewGround: CapabilityHandler = async (_ctx, node, args) => {
const t = target(args, node);
return runWithImplicitWait(t, "scrollIntoView", propertyValue([
"$ok = $false; $method = 'scroll_item_pattern'",
"try { $el.GetCurrentPattern([System.Windows.Automation.ScrollItemPattern]::Pattern).ScrollIntoView(); $ok = $true } catch {}",
"if (-not $ok) { $method = 'focus'; try { $el.SetFocus() | Out-Null; $ok = $true } catch {} }",
"Write-Output (@{ scrolled = $ok; method = $method; name = $el.Current.Name } | ConvertTo-Json -Compress)"
], t), runScript, args);
};
function childrenGroundHandler(single: boolean): CapabilityHandler {
return async (_ctx, node, args) => {
const t = target(args, node);
const num = Math.max(0, Math.floor(Number(args.num ?? 0) || 0));
const result = await runWithImplicitWait(t, "getChildren", propertyValue([
"$walker = [System.Windows.Automation.TreeWalker]::ControlViewWalker; $child = $walker.GetFirstChild($el); $children = @()",
"while ($null -ne $child) {",
" $r = $child.Current.BoundingRectangle",
" $children += @{ name = $child.Current.Name; id = $child.Current.AutomationId; type = $child.Current.ControlType.ProgrammaticName; className = $child.Current.ClassName; enabled = [bool]$child.Current.IsEnabled; x = [int]$r.X; y = [int]$r.Y; width = [int]$r.Width; height = [int]$r.Height }",
" $child = $walker.GetNextSibling($child)",
"}",
single ? `$n = ${num}; if ($n -ge 1 -and $n -le $children.Count) { $children = @($children[$n - 1]) }` : "",
"Write-Output (@{ result = $children; count = $children.Count } | ConvertTo-Json -Compress -Depth 4)"
], t), runScript, args);
return result.result ?? [];
};
}
register("Element_GetCheckGround", getCheckGround);
register("Element_SetCheckGround", setCheckGround);
register("Element_RegionGround", regionGround);
register("Element_GetSelectGround", getSelectGround);
register("Element_SetSelectGround", setSelectGround);
register("Element_GetAttributeGround", getAttributeGround);
register("Element_SetAttributeGround", setAttributeGround);
register("Element_GetButtonEnableGround", getButtonEnableGround);
register("Element_ShotGround", shotGround);
register("Element_MouseToGround", mouseToGround);
register("Element_ScrollIntoViewGround", scrollIntoViewGround);
register("Element_ChildrenGround", childrenGroundHandler(false));
register("Element_DesktopChildren", childrenGroundHandler(true));
register("Element_RestoreWindowGround", restoreWindowGround);
register("Element_ActivateWindowGround", activateWindowGround);
register("Element_MoveWindowGround", moveWindowGround);
+82 -6
View File
@@ -4,6 +4,37 @@ import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
interface ManualOperationSettings {
enabled: boolean;
randomMove: boolean;
randomClick: boolean;
randomSleep: boolean;
minMs: number;
maxMs: number;
}
const manualOperation: ManualOperationSettings = {
enabled: false, randomMove: true, randomClick: true, randomSleep: true, minMs: 1000, maxMs: 3000
};
function jitter(value: number, amount = 3): number {
return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount)));
}
function manualJitter(point: [number, number], move: boolean, click: boolean): [number, number] {
if (!manualOperation.enabled) return point;
const amount = (click && manualOperation.randomClick) || (move && manualOperation.randomMove) ? 3 : 0;
return [jitter(point[0], amount), jitter(point[1], amount)];
}
async function manualDelay(): Promise<void> {
if (!manualOperation.enabled || !manualOperation.randomSleep) return;
const low = Math.max(0, manualOperation.minMs);
const high = Math.max(low, manualOperation.maxMs);
const timeout = low + Math.random() * (high - low);
if (timeout > 0) await new Promise((resolve) => setTimeout(resolve, timeout));
}
const USER32 = `Add-Type @' const USER32 = `Add-Type @'
using System; using System;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
@@ -23,6 +54,10 @@ function text(args: Record<string, unknown>, keys: string[], fallback = ""): str
return fallback; return fallback;
} }
function bool(value: unknown): boolean {
return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never);
}
function number(args: Record<string, unknown>, keys: string[], fallback = 0): number { function number(args: Record<string, unknown>, keys: string[], fallback = 0): number {
const value = Number(text(args, keys, String(fallback))); const value = Number(text(args, keys, String(fallback)));
return Number.isFinite(value) ? value : fallback; return Number.isFinite(value) ? value : fallback;
@@ -66,12 +101,31 @@ export function psString(value: unknown): string {
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => { const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const keys = text(args, ["keys", "key", "text", "value"]); const keys = text(args, ["keys", "key", "text", "value"]);
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId }); if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
await manualDelay();
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`); await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`);
await manualDelay();
return keys; return keys;
}; };
const enableManualOperation: CapabilityHandler = async (_ctx, _node, args) => {
const min = number(args, ["min_time", "minTime"], 1) * 1000;
const max = number(args, ["max_time", "maxTime"], 3) * 1000;
manualOperation.enabled = true;
manualOperation.randomMove = bool(args.random_move ?? true);
manualOperation.randomClick = bool(args.random_click ?? true);
manualOperation.randomSleep = bool(args.random_sleep ?? true);
manualOperation.minMs = min;
manualOperation.maxMs = Math.max(min, max);
return { enabled: true, randomMove: manualOperation.randomMove, randomClick: manualOperation.randomClick, randomSleep: manualOperation.randomSleep, minMs: manualOperation.minMs, maxMs: manualOperation.maxMs };
};
const disableManualOperation: CapabilityHandler = async () => {
manualOperation.enabled = false;
return { enabled: false };
};
const mouseClick: CapabilityHandler = async (_ctx, node, args) => { const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]"); const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true);
const button = text(args, ["side", "button"], "0").toLowerCase(); const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002; const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004; const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
@@ -80,7 +134,7 @@ const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
}; };
const mouseMove: CapabilityHandler = async (_ctx, node, args) => { const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]"); const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false);
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`); await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
return { x, y }; return { x, y };
}; };
@@ -99,22 +153,42 @@ const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
}; };
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => { const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]); const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`); const result = await powershell(`${USER32}
$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1
if (-not $p) { throw '找不到窗口' }
Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr pid); [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint a, uint b, bool attach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId();' -Name RpaFocus2 -Namespace WinRpaFocus2 | Out-Null
$h = $p.MainWindowHandle
if ([WinRpaFocus2.RpaFocus2]::IsIconic($h)) { [void][WinRpaFocus2.RpaFocus2]::ShowWindow($h, 9) }
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 0, [UIntPtr]::Zero)
[void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h)
[void][WinRpaFocus2.RpaFocus2]::keybd_event(0x12, 0, 2, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) {
$fgThread = [WinRpaFocus2.RpaFocus2]::GetWindowThreadProcessId([WinRpaFocus2.RpaFocus2]::GetForegroundWindow(), [IntPtr]::Zero)
$thisThread = [WinRpaFocus2.RpaFocus2]::GetCurrentThreadId()
$attached = [WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $true)
try { [void][WinRpaFocus2.RpaFocus2]::SetForegroundWindow($h) } finally { if ($attached) { [void][WinRpaFocus2.RpaFocus2]::AttachThreadInput($thisThread, $fgThread, $false) } }
Start-Sleep -Milliseconds 200
}
if ([WinRpaFocus2.RpaFocus2]::GetForegroundWindow() -ne $h) { throw '前台切换失败' }
$p.MainWindowTitle`);
return result; return result;
}; };
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`); const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
const windowExists: CapabilityHandler = async (_ctx, node, args) => { const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]); const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true"; return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
}; };
const closeWindow: CapabilityHandler = async (_ctx, node, args) => { const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "title", "target_window", "window_title"]); const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId }); if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`); return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
}; };
@@ -151,6 +225,8 @@ const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend); register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
register("Keyboard_EnableManualOperation", enableManualOperation);
register("Keyboard_DisableManualOperation", disableManualOperation);
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick); register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove); register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag); register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag);
@@ -0,0 +1,19 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
describe("duplicate capability policy", () => {
it("keeps one canonical capability and marks compatible aliases", async () => {
const audit = JSON.parse(await readFile("resources/capability-duplicates.json", "utf8"));
const decisions = audit.decisions as Array<{ id: string; status: string; canonical?: string }>;
const kept = decisions.filter((item) => item.status === "keep");
const hidden = decisions.filter((item) => item.status === "hide");
expect(kept.length).toBe(654);
expect(hidden.length).toBe(240);
expect(audit.editableBefore).toBe(kept.length + hidden.length);
expect(audit.editableAfter).toBe(kept.length);
const keptIds = new Set(kept.map((item) => item.id));
expect(keptIds.size).toBe(kept.length);
for (const item of hidden) { if (!item.canonical) throw new Error("hidden capability missing canonical"); expect(keptIds.has(item.canonical)).toBe(true); }
expect(hidden.every((item) => item.canonical && !keptIds.has(item.id))).toBe(true);
});
});
+63
View File
@@ -0,0 +1,63 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import ExcelJS from "exceljs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import "./excel-com";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) {
const handler = getHandler(id);
expect(handler, id).toBeTruthy();
return handler!(context, node(id), args, component);
}
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
const registered = [
"Excel_Cs_Group", "Excel_Cs_DeletePicture", "Excel_Cs_RefreshPivotTable", "Excel_Cs_DeletePictureV2",
"Excel_Cs_Encrypt", "Excel_Cs_Move", "Excel_Cs_InsertPicture", "Excel_Cs_Merge", "Excel_InsertImage",
"Excel_Cs_InsertPivotView", "Excel_Cs_Interop", "Excel_Cs_InsertPivotTable", "Excel_Cs_RunMacro",
"Excel_Loop", "Excel_Cs_Export", "Excel_Cs_ExcelAddin", "Excel_Cs_InsertPictureV2",
"Excel_Cs_FilterPivotTable", "Excel_Cs_RunMacroV2", "Excel_Cs_SetSlicer", "Excel_ExecuteMacro",
"Excel_Cs_RefreshAll", "Excel_DeleteImage", "Excel_ExportRangeAsPicture"
];
describe("Excel COM capabilities", () => {
it("registers every Excel COM capability", () => { for (const id of registered) expect(getHandler(id), id).toBeTruthy(); });
it("groups rows, moves sheets, reads dynamic properties and loops content", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-com-")); directories.push(directory);
const filePath = path.join(directory, "workbook.xlsx");
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("Sheet1").getCell("A1").value = "A1";
workbook.addWorksheet("Second").getCell("A1").value = "S1";
await workbook.xlsx.writeFile(filePath);
await expect(run("Excel_Cs_Group", { workbook: { path: filePath }, sheet: "Sheet1", group: 0, groupType: 0, startRow: 1, endRow: 2, save: true })).resolves.toMatchObject({ grouped: true });
await expect(run("Excel_Cs_Interop", { workbook: { path: filePath }, obj: 2, sheet: "Sheet1", property: "Name" })).resolves.toMatchObject({ value: "Sheet1" });
await expect(run("Excel_Cs_Move", { workbook: { path: filePath }, sheet: "Second", mode: "按序号", index: 1, save: true })).resolves.toMatchObject({ moved: true });
await expect(run("Excel_Cs_RefreshAll", { workbook: { path: filePath }, save: true })).resolves.toMatchObject({ refreshed: true });
const loop = await run("Excel_Loop", { objExcel: { path: filePath } }) as unknown[][];
expect(loop.length).toBeGreaterThanOrEqual(2);
expect(loop.flat().join(",")).toContain("A1");
}, 120000);
it("exports a workbook to PDF through Excel COM", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-excel-pdf-")); directories.push(directory);
const filePath = path.join(directory, "source.xlsx");
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("Report").getCell("A1").value = "PDF";
await workbook.xlsx.writeFile(filePath);
const output = await run("Excel_Cs_Export", { workbook: { path: filePath }, option: "全部", path: directory, name: "report.pdf", cover: true }) as string;
expect(output).toBe(path.join(directory, "report.pdf"));
expect((await readFile(output)).subarray(0, 5).toString()).toMatch(/^%PDF-/);
}, 120000);
});
+288
View File
@@ -0,0 +1,288 @@
import fs from "node:fs";
import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
import { releaseWorkbook } from "./excel-store";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
const value = args[key];
if (value !== undefined && value !== null && value !== "") return String(value);
}
return fallback;
}
function bool(value: unknown, fallback = false): boolean {
if (value === undefined || value === null || value === "") return fallback;
return [true, 1, "1", "true", "True", "TRUE", "yes", "是", "开启"].includes(value as never);
}
function number(value: unknown, fallback = 0): number { const result = Number(value); return Number.isFinite(result) ? result : fallback; }
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string, keys = ["workbook", "objExcel", "objExcelWorkBook", "destWorkbook"]): string {
for (const key of keys) {
const raw = args[key];
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
const value = String(candidate ?? "").trim();
if (value && value !== "Excel文件" && value !== "objExcelWorkBook") return path.resolve(value);
}
throw new RuntimeError("validation_error", "Excel文件路径为空", { nodeId, componentId });
}
function sheetExpression(args: Record<string, unknown>): string {
const sheet = text(args, ["sheet", "sheetName"]);
return sheet ? `$workbook.Worksheets.Item(${psString(sheet)})` : "$workbook.ActiveSheet";
}
async function runExcelCom(filePath: string, body: string, save = false, readonly = false, timeout = 120000): Promise<unknown> {
const openMode = readonly ? "$true" : "$false";
const saveMode = save ? "$true" : "$false";
const script = [
"$excel=$null;$workbook=$null;$rpaOutput=$null",
"try {",
" $excel=New-Object -ComObject Excel.Application;$excel.Visible=$false;$excel.DisplayAlerts=$false",
" $workbook=$excel.Workbooks.Open(" + psString(filePath) + ",0," + openMode + ")",
" " + body,
" if (" + saveMode + ") { $workbook.Save() }",
" if ($null -ne $rpaOutput) { [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes(($rpaOutput | ConvertTo-Json -Compress -Depth 6))) | Write-Output }",
"} finally {",
" if($null -ne $workbook){try{$workbook.Close($false)}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($workbook)}catch{}}",
" if($null -ne $excel){try{$excel.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($excel)}catch{}}",
" [GC]::Collect();[GC]::WaitForPendingFinalizers()",
"}"
].join("\n");
const output = (await powershell(script, timeout)).trim();
if (save) releaseWorkbook(filePath);
if (!output) return null;
try { return JSON.parse(Buffer.from(output, "base64").toString("utf8")); }
catch { throw new RuntimeError("excel_com_failed", "Excel COM 输出异常", { detail: output.slice(0, 300) }); }
}
function list(value: unknown): string[] {
if (Array.isArray(value)) return value.map((item) => String(item)).filter(Boolean);
const raw = String(value ?? "").trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; }
catch { return raw.split(/[,,;;\s]+/).filter(Boolean); }
}
const group: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const ungroup = String(args.group ?? "0") === "1";
const columns = String(args.groupType ?? "0") === "1";
const start = columns ? text(args, ["startColumn"], "A") : String(number(args.startRow, 1));
const end = columns ? text(args, ["endColumn"], text(args, ["startColumn"], "A")) : String(number(args.endRow, number(args.startRow, 1)));
const method = ungroup ? "Ungroup" : "Group";
const body = `$sheet=${sheetExpression(args)};$start=${psString(start)};$end=${psString(end)};
$count=if(${columns ? "$true" : "$false"}){$sheet.UsedRange.Columns.Count}else{$sheet.UsedRange.Rows.Count};
foreach($name in @("start","end")){ $v=[int](Get-Variable $name -ValueOnly); if($v -lt 0){ Set-Variable $name ($count+$v+1) } };
$range=$sheet.Range("$($start):$($end)");$range.${method}|Out-Null`;
await runExcelCom(filePath, body, bool(args.save));
return { grouped: !ungroup, type: columns ? "columns" : "rows" };
};
const deletePicture: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
const name = text(args, ["name", "pictureName"]);
if (!name) throw new RuntimeError("validation_error", "图片名称为空", { nodeId: node.id, componentId: node.componentId });
const body = `$sheet=${sheetExpression(args)};$sheet.Shapes.Item(${psString(name)}).Delete()`;
await runExcelCom(filePath, body, bool(args.save));
return { deleted: name };
};
const insertPicture: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
const image = path.resolve(text(args, ["path", "pictureName"]));
if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "图片文件不存在", { nodeId: node.id, componentId: node.componentId, detail: image });
const row = number(args.row, 1); const column = number(args.column, 1);
const left = number(args.Left ?? args.left, 20); const top = number(args.Top ?? args.top, 20);
const width = number(args.Width ?? args.width, -1); const height = number(args.Height ?? args.height, -1);
const scale = width > 0 && height > 0 ? "$true" : "$false";
const body = `$sheet=${sheetExpression(args)};$cell=$sheet.Cells(${row},${column});$left=${left}+if($left -gt 0){$cell.Left}else{0};$top=${top}+if($top -gt 0){$cell.Top}else{0};[void]$sheet.Shapes.AddPicture(${psString(image)},$false,${scale},$left,$top,${width},${height})`;
await runExcelCom(filePath, body, bool(args.save));
return { inserted: image };
};
const encryptExcel: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const password = String(args.password1 ?? args.password ?? "");
const confirm = String(args.password2 ?? password);
if (!password) throw new RuntimeError("validation_error", "Excel密码为空", { nodeId: node.id, componentId: node.componentId });
if (password !== confirm) throw new RuntimeError("validation_error", "两次输入的Excel密码不一致", { nodeId: node.id, componentId: node.componentId });
const temporary = `${filePath}.${Date.now()}.enc.xlsx`;
try {
await runExcelCom(filePath, `$workbook.SaveAs(${psString(temporary)},51,${psString(password)},${psString(confirm)})`, false);
fs.rmSync(temporary, { force: true });
// COM SaveAs cannot replace the workbook while it is open, so save beside it and rename after Close.
} finally {
if (fs.existsSync(temporary)) {
fs.rmSync(filePath, { force: true });
fs.renameSync(temporary, filePath);
}
releaseWorkbook(filePath);
}
return filePath;
};
const moveSheet: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const mode = text(args, ["mode"], "按序号"); const after = text(args, ["option"], "之前").includes("之后");
const targetName = text(args, ["name", "index"]); const position = text(args, ["position", "index"], "第一位");
const index = number(args.index, 1);
const afterMode = after ? "$true" : "$false";
const body = `$sheet=${sheetExpression(args)};$count=$workbook.Worksheets.Count;$targetIndex=0;
if(${psString(mode)} -eq "按名称"){ $targetName=${psString(targetName)}; for($i=1;$i -le $count;$i++){ if($workbook.Worksheets.Item($i).Name -eq $targetName){$targetIndex=$i;break} }; if($targetIndex -eq 0){throw "target_sheet_not_found"} }
elseif(${psString(mode)} -eq "按位置"){ $p=${psString(position)}; if($p -eq "第一位"){$targetIndex=1}elseif($p -eq "最后一位"){$targetIndex=$count}else{ $delta=-1; if($p -eq "向后移一位"){$delta=1}; $targetIndex=$sheet.Index+$delta } }
else { $targetIndex=${index}; if($targetIndex -lt 0){$targetIndex=$count+$targetIndex+1} };
$targetIndex=[Math]::Min([Math]::Max(1,$targetIndex),$count);$target=$workbook.Worksheets.Item($targetIndex);
if(${afterMode}){ if($target.Index -ne $sheet.Index){ $sheet.Move([Type]::Missing,$target)|Out-Null } }
else { if($target.Index -ne $sheet.Index){ $sheet.Move($target)|Out-Null } }`;
await runExcelCom(filePath, body, bool(args.save));
return { moved: true };
};
const mergeSheet: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const destPath = workbookPath(args, node.id, node.componentId, ["destWorkbook", "workbook"]);
const horizontal = String(args.option ?? "0") === "1";
const horizontalMode = horizontal ? "$true" : "$false";
const saveDest = bool(args.save) ? "$true" : "$false";
const body = `$source=${sheetExpression(args)};$dest=$excel.Workbooks.Open(${psString(destPath)});$target=$dest.Worksheets.Item(${psString(text(args, ["destSheet"], "Sheet1"))});
if(${horizontalMode}){$offset=$target.UsedRange.Columns.Count+1}else{$offset=$target.UsedRange.Rows.Count+1};
for($r=1;$r -le $source.UsedRange.Rows.Count;$r++){for($c=1;$c -le $source.UsedRange.Columns.Count;$c++){ if(${horizontalMode}){$target.Cells($r,$offset+$c-1).Value2=$source.Cells($r,$c).Value2}else{$target.Cells($offset+$r-1,$c).Value2=$source.Cells($r,$c).Value2}}};
if(${saveDest}){$dest.Save()}`;
await runExcelCom(filePath, body, false);
return { merged: true, target: destPath };
};
const refreshAll: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
await runExcelCom(filePath, "$workbook.RefreshAll()", bool(args.save));
return { refreshed: true };
};
const refreshPivot: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const name = text(args, ["pivotTable"]); const sheetExpr = sheetExpression(args);
const body = name ? `${sheetExpr}.PivotTables(${psString(name)}).RefreshTable()` : `${sheetExpr}.PivotTables() | ForEach-Object { $_.RefreshTable() }`;
await runExcelCom(filePath, body, bool(args.save));
return { refreshed: true, name: name || "all" };
};
const filterPivot: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const field = text(args, ["field"]); const values = list(args.fieldValue);
if (!field) throw new RuntimeError("validation_error", "筛选字段为空", { nodeId: node.id, componentId: node.componentId });
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
const body = `$pt=${sheetExpression(args)}.PivotTables(${psString(text(args, ["pivotTable"]))});$pf=$pt.PivotFields(${psString(field)});
try{$pf.ClearAllFilters()}catch{};
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
foreach($item in $pf.PivotItems()){ $selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name}; $item.Visible=$selected }`;
await runExcelCom(filePath, body, bool(args.save));
return { field, values };
};
const createPivot: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const chart = node.componentId.includes("PivotView");
let rule: Record<string, unknown> = {};
try { rule = JSON.parse(String(args.rule || "{}")) as Record<string, unknown>; } catch { /* sourceData can still be supplied separately. */ }
const source = text({ ...rule, ...args }, ["SourceData", "sourceData", "source"]);
if (!source) throw new RuntimeError("validation_error", "透视表数据源为空", { nodeId: node.id, componentId: node.componentId });
const name = text(rule, ["Name", "name"], chart ? "RPAPivotChart" : "RPAPivotTable");
const body = `$sheet=${sheetExpression(args)};$cache=$excel.ActiveWorkbook.PivotCaches().Create(1,$sheet.Range(${psString(source)}));
$dest=$sheet.Cells(1,20);$pt=$cache.CreatePivotTable($dest,${psString(name)});
if(${chart ? "$true" : "$false"}){[void]$sheet.Shapes.AddChart(-4109,20,20,420,260)}`;
await runExcelCom(filePath, body, bool(args.save));
return { name, chart };
};
const runMacro = (scriptKey: "script" | "code" | "macroName"): CapabilityHandler => async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
const macro = text(args, [scriptKey, "script", "code", "macroName", "name"]);
if (!macro) throw new RuntimeError("validation_error", "宏名称为空", { nodeId: node.id, componentId: node.componentId });
const parameters = list(args.args ?? args.macroArgs).map((item) => psString(item)).join(",");
const body = `$result=$excel.Run(${psString(macro)}${parameters ? "," + parameters : ""});$rpaOutput=@{result=$result}`;
return await runExcelCom(filePath, body, bool(args.save), false, 180000);
};
const interop: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook", "objExcel"]);
const property = text(args, ["property"]);
if (!property) throw new RuntimeError("validation_error", "属性或方法名为空", { nodeId: node.id, componentId: node.componentId });
const objectKind = number(args.obj ?? args.object, 0);
const range = text(args, ["range"], "A1");
const target = objectKind === 0 ? "$excel" : objectKind === 1 ? "$workbook" : objectKind === 2 ? sheetExpression(args) : `${sheetExpression(args)}.Range(${psString(range)})`;
const value = args.value;
const empty = value === undefined || value === null || value === "";
const body = empty
? `$target=${target};$rpaOutput=@{value=$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::GetProperty,$null,$target,@())}`
: `$target=${target};[void]$target.GetType().InvokeMember(${psString(property)},[Reflection.BindingFlags]::SetProperty,$null,$target,@(${psString(String(value))}));$rpaOutput=@{set=$true}`;
return await runExcelCom(filePath, body, bool(args.save));
};
const exportPdf: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const folder = text(args, ["path"]);
const name = text(args, ["name"], path.basename(filePath, path.extname(filePath)) + ".pdf");
const output = path.join(path.resolve(folder), /\.(pdf)$/i.test(name) ? name : `${name}.pdf`);
fs.mkdirSync(path.dirname(output), { recursive: true });
if (!bool(args.cover, true) && fs.existsSync(output)) throw new RuntimeError("validation_error", "PDF 文件已存在", { detail: output });
const activateOnly = text(args, ["option"], "全部") === "激活页";
const body = `if(${activateOnly ? "$true" : "$false"}){$workbook.ActiveSheet.ExportAsFixedFormat(0,${psString(output)})}else{$workbook.ExportAsFixedFormat(0,${psString(output)})}`;
await runExcelCom(filePath, body, false);
return output;
};
const exportRangePicture: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const folder = text(args, ["path"]); const name = text(args, ["name"], `excel-range-${Date.now()}`);
const output = path.join(path.resolve(folder), /\.(png|jpg|jpeg)$/i.test(name) ? name : `${name}.png`);
fs.mkdirSync(path.dirname(output), { recursive: true });
const body = `$range=${sheetExpression(args)}.Range(${psString(text(args, ["range"], "A1"))});$range.CopyPicture(1,2)|Out-Null;$shape=${sheetExpression(args)}.ChartObjects().Add(0,0,$range.Width,$range.Height);$chart=$shape.Chart;$chart.Paste();[void]$chart.Export(${psString(output)},"PNG");$shape.Delete()`;
await runExcelCom(filePath, body, false);
return output;
};
const setSlicer: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["workbook"]);
const name = text(args, ["slicerName"]); const values = list(args.slicerOptions);
if (!name) throw new RuntimeError("validation_error", "切片器名称为空", { nodeId: node.id, componentId: node.componentId });
const encoded = Buffer.from(JSON.stringify(values), "utf16le").toString("base64");
const body = `$cache=$workbook.SlicerCaches.Item(${psString(name)});
$wanted=[Text.Encoding]::Unicode.GetString([Convert]::FromBase64String("${encoded}"))|ConvertFrom-Json;
foreach($item in $cache.SlicerItems()){ $item.Selected=if($wanted.Count -eq 0){$true}else{$wanted -contains $item.Name} }`;
await runExcelCom(filePath, body, bool(args.save));
return { slicer: name, values };
};
const excelLoop: CapabilityHandler = async (_ctx, node, args) => {
const filePath = workbookPath(args, node.id, node.componentId, ["objExcel", "workbook"]);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
const rows: unknown[][] = [];
for (const sheet of workbook.worksheets) {
for (let row = 1; row <= sheet.rowCount; row += 1) {
const cells: unknown[] = [];
for (let column = 1; column <= sheet.columnCount; column += 1) cells.push(sheet.getCell(row, column).value ?? "");
if (cells.some((cell) => cell !== "")) rows.push(cells);
}
}
return rows;
};
register("Excel_Cs_Group", group);
register("Excel_Cs_Encrypt", encryptExcel);
register(["Excel_Cs_DeletePicture", "Excel_Cs_DeletePictureV2", "Excel_DeleteImage"], deletePicture);
register(["Excel_Cs_InsertPicture", "Excel_Cs_InsertPictureV2", "Excel_InsertImage"], insertPicture);
register("Excel_Cs_Move", moveSheet);
register("Excel_Cs_Merge", mergeSheet);
register(["Excel_Cs_RefreshAll", "Excel_Cs_RefreshPivotTable"], async (ctx, node, args, component) => node.componentId === "Excel_Cs_RefreshPivotTable" ? refreshPivot(ctx, node, args, component) : refreshAll(ctx, node, args, component));
register("Excel_Cs_FilterPivotTable", filterPivot);
register(["Excel_Cs_InsertPivotTable", "Excel_Cs_InsertPivotView"], createPivot);
register(["Excel_Cs_RunMacro", "Excel_Cs_RunMacroV2", "Excel_ExecuteMacro"], runMacro("script"));
register("Excel_Cs_Interop", interop);
register("Excel_Cs_Export", exportPdf);
register("Excel_Cs_ExcelAddin", runMacro("code"));
register("Excel_Cs_SetSlicer", setSlicer);
register("Excel_Loop", excelLoop);
register("Excel_ExportRangeAsPicture", exportRangePicture);
+5
View File
@@ -24,15 +24,20 @@ import "./word";
import "./word-extended"; import "./word-extended";
import "./message"; import "./message";
import "./vision"; import "./vision";
import "./cv-vision";
import "./extended"; import "./extended";
import "./system-extended"; import "./system-extended";
import "./office-file-extended"; import "./office-file-extended";
import "./office-aliases"; import "./office-aliases";
import "./excel-common"; import "./excel-common";
import "./excel-com";
import "./window-extended"; import "./window-extended";
import "./data-extended"; import "./data-extended";
import "./utility-extended"; import "./utility-extended";
import "./data-table-common"; import "./data-table-common";
import "./basic-encryption";
import "./mail-remaining";
import "./remaining-core";
import "./common-final"; import "./common-final";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry"; export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
+60
View File
@@ -0,0 +1,60 @@
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { findImapMail, type NormalizedMail } from "./message";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
return fallback;
}
function number(value: unknown, fallback = 1): number { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback; }
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" ? value as Record<string, unknown> : (() => { try { return JSON.parse(String(value || "{}")); } catch { return {}; } })(); }
function list(value: unknown): string[] { if (Array.isArray(value)) return value.map(String); try { const parsed = JSON.parse(String(value || "[]")); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; } catch { return String(value || "").split(/[,,;;\s]+/).filter(Boolean); } }
function mergeAccount(args: Record<string, unknown>): Record<string, unknown> { return { ...record(args.account_obj || args.recvObject || args.target_obj), ...args }; }
async function imapClient(args: Record<string, unknown>) {
const { ImapFlow } = await import("imapflow"); const config = mergeAccount(args);
const client = new ImapFlow({ host: text(config, ["host", "hostname", "server_address", "server"]), port: number(config.port, 993), secure: ["true", "1", "True"].includes(text(config, ["ssl", "is_ssl", "secure"], "True")), auth: { user: text(config, ["account", "username", "user"]), pass: text(config, ["password", "pass"]) }, logger: false });
await client.connect(); return client;
}
async function withMailbox<T>(args: Record<string, unknown>, callback: (client: any) => Promise<T>): Promise<T> {
const client = await imapClient(args); const lock = await client.getMailboxLock(text(args, ["folder", "email_folder", "inbox_type", "folder_name"], "INBOX"));
try { return await callback(client); } finally { lock.release(); client.close(); }
}
async function searchMails(args: Record<string, unknown>): Promise<NormalizedMail[]> {
const normalized: Record<string, unknown> = { ...args, From_data: text(args, ["From_data", "From", "sender_key", "recipient_key"]), Subject_data: text(args, ["Subject_data", "Subject", "subject_key"]), Body_data: text(args, ["Body_data", "body_key"]) };
return await findImapMail(normalized, { id: "mail-remaining", componentId: "mail-remaining" });
}
const connect: CapabilityHandler = async (_ctx, _node, args) => { const config = mergeAccount(args); await imapClient(config).then((client) => client.close()); return { kind: "imap", ...config }; };
register(["Mail_RecvConnect", "Mail_UnionConnect", "Mail_ExConnect"], connect);
register("Mail_RecvClose", async () => ({ closed: true }));
register("Mail_RecvGetList", async (_ctx, _node, args) => await searchMails(args));
register("Mail_ExSearchMail", async (_ctx, _node, args) => await searchMails(args));
register("Mail_RecvMailGet", async (_ctx, _node, args) => {
const mails = await searchMails(args); const wanted = number(args.limit || args.index, 1); const mail = mails[wanted - 1];
if (!mail) throw new RuntimeError("not_found", "未找到第 " + wanted + " 封邮件"); return mail;
});
register("Mail_RecvCount", async (_ctx, _node, args) => await withMailbox(args, async (client) => client.mailbox.exists));
async function setSeen(args: Record<string, unknown>, seen: boolean) {
return await withMailbox(args, async (client) => { const sequence = String(number(args.index || args.limit, 1)); const flag = String.fromCharCode(92) + "Seen";
return seen ? await client.messageFlagsAdd(sequence, [flag]) : await client.messageFlagsRemove(sequence, [flag]); });
}
register("Mail_MarkSeen", async (_ctx, _node, args) => setSeen(args, true));
register("Mail_UnmarkSeen", async (_ctx, _node, args) => setSeen(args, false));
register(["Mail_DoMoveMail", "Mail_OperateMail", "Mail_ExOperateMail", "Mail_DoMoveMail_v1"], async (_ctx, _node, args) => {
const destination = text(args, ["DstFile", "destination", "folder_name", "email_folder"], "Archive");
return await withMailbox(args, async (client) => await client.messageMove(String(number(args.index || args.limit, 1)), destination));
});
async function invoke(id: string, args: Record<string, unknown>) { const handler = getHandler(id); if (!handler) throw new RuntimeError("capability_not_implemented", id + " 不可用"); return handler; }
register("Mail_DoSendMail", async (ctx, node, args) => {
const handler = await invoke("Mail_Send", args); const config = mergeAccount(args);
return await handler(ctx, node, { ...args, host: text(config, ["host", "server_address"]), account: text(config, ["Account", "account", "username"]), password: text(config, ["password"]), to: list(args.RecvByEmail), cc: list(args.CC), bcc: list(args.BCC), subject: text(args, ["Subject"]), content: text(args, ["Body", "body"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
});
register("Mail_DoSendMail_v1", async (ctx, node, args) => { const handler = await invoke("Mail_DoSendMail", args); return await handler(ctx, node, { ...args, account_obj: mergeAccount(args), RecvByEmail: args.RecvByEmail || args.target_email, CC: args.CC || args.cc, Subject: args.Subject || args.subject, Body: args.Body || args.body || args.content, filePath: args.filePath || args.attachment }, {} as never); });
register("Mail_ExSendMail", async (ctx, node, args) => { const handler = await invoke("Mail_Send", args);
return await handler(ctx, node, { ...args, to: list(args.target_email), cc: list(args.cc), subject: text(args, ["subject"]), content: text(args, ["body"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
});
register("Mail_DoReplyMail", async (ctx, node, args) => { const handler = await invoke("Mail_ReplyMail_v2", args);
return await handler(ctx, node, { ...args, subject: text(args, ["Subject"], "Re:"), content: text(args, ["ReplyBody", "content"]), attachment: text(args, ["filePath", "attachment"]) }, {} as never);
});
register("Mail_DoReplyMail_v1", async (ctx, node, args) => { const handler = await invoke("Mail_ReplyMail_v2", args);
return await handler(ctx, node, { ...args, subject: text(args, ["subject"], "Re:"), content: text(args, ["content", "ReplyBody"]), attachment: text(args, ["attachment"]) }, {} as never);
});
@@ -0,0 +1,67 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler, hasCapability } from "../engine/registry";
import "./message";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ids = [
"Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2",
"Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2",
"Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"
];
afterEach(async () => {});
describe("mail receive and reply", () => {
it("registers find, reply and Outlook message capabilities", () => {
for (const id of ids) expect(hasCapability(id), id).toBe(true);
});
it("replies through SMTP with the selected mail sender", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += line + "\n";
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^MAIL FROM/i.test(line)) socket.write("250 ok\r\n");
else if (/^RCPT TO/i.test(line)) socket.write("250 ok\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = (server.address() as net.AddressInfo).port;
try {
const handler = getHandler("Mail_ReplyMail_v2");
expect(handler).toBeTruthy();
const result = await handler!(context, node("Mail_ReplyMail_v2"), {
account_obj: { host: "127.0.0.1", port, account: "robot@example.com", secure: "False" },
mail_obj: { from: "owner@example.com", subject: "status", messageId: "<abc@example.com>" },
content: "reply delivered"
}, component);
expect(result).toMatchObject({ accepted: ["owner@example.com"] });
expect(received).toContain("reply delivered");
expect(received).toContain("Re: status");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30000);
});
+130
View File
@@ -1,5 +1,7 @@
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string { function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]); for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
@@ -42,3 +44,131 @@ register(["Mail_Send", "Mail_Send_V1", "Mail_UnionSend", "Mail_UnionSend_v1"], a
transporter.close(); transporter.close();
} }
}); });
export interface NormalizedMail {
uid: string; from: string; to: string[]; cc: string[]; subject: string;
text: string; html: string; date: string; messageId: string; hasAttachment: boolean;
attachmentNames: string[];
}
export function normalizeAccount(args: Record<string, unknown>): Record<string, unknown> {
return accountObject(args.account_obj ?? args.accountObj ?? args.account);
}
function addressList(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.map((item) => String(typeof item === "object" && item && "text" in (item as Record<string, unknown>) ? (item as { text: unknown }).text : item)).filter(Boolean);
if (typeof value === "object") { const record = value as { text?: unknown; value?: unknown }; return addressList(record.text ?? record.value); }
return String(value).split(/[;,,;\s]+/).filter(Boolean);
}
export function normalizeMail(input: Record<string, unknown>, uid = ""): NormalizedMail {
const attachments = Array.isArray(input.attachments) ? input.attachments as Array<Record<string, unknown>> : [];
return {
uid: String(input.uid ?? uid),
from: String(input.from ?? input.sender ?? ""),
to: addressList(input.to), cc: addressList(input.cc),
subject: String(input.subject ?? ""),
text: String(input.text ?? input.body ?? ""),
html: String(input.html ?? ""),
date: input.date instanceof Date ? input.date.toISOString() : String(input.date ?? input.receivedTime ?? ""),
messageId: String(input.messageId ?? ""),
hasAttachment: Boolean(input.hasAttachment ?? attachments.length),
attachmentNames: attachments.map((item) => String(item.filename ?? item.name ?? "")).filter(Boolean)
};
}
function matches(mail: NormalizedMail, args: Record<string, unknown>): boolean {
const contains = (needle: unknown, haystack: string): boolean => !needle || haystack.toLowerCase().includes(String(needle).toLowerCase());
return contains(args.From_data, mail.from)
&& contains(args.To_data, mail.to.join(" "))
&& contains(args.Subject_data, mail.subject)
&& contains(args.Body_data, mail.text + "\n" + mail.html)
&& (args.attachment === undefined || String(args.attachment) === "2" || (String(args.attachment) === "1") === mail.hasAttachment);
}
export async function findImapMail(args: Record<string, unknown>, node: { id: string; componentId: string }): Promise<NormalizedMail[]> {
const configured = normalizeAccount(args);
const merged = { ...args, ...configured };
const account = text(merged, ["account", "username", "user", "from"]);
const password = text(merged, ["password", "pass", "token"]);
const host = text(merged, ["imap", "imapHost", "imap_host", "server", "host"]);
if (!host || !account) throw new RuntimeError("validation_error", "IMAP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
const port = Number(text(merged, ["imapPort", "imap_port", "recvPort", "port"], "993"));
const secure = ["true", "1", "是", "True"].includes(text(merged, ["imap_ssl", "imapSsl", "is_ssl", "secure"], "True"));
const folder = text(args, ["folder_name", "email_folder", "folder"], "INBOX");
const { ImapFlow } = await import("imapflow");
const client = new ImapFlow({ host, port, secure, auth: { user: account, pass: password }, logger: false });
const mails: NormalizedMail[] = [];
try {
await client.connect();
const lock = await client.getMailboxLock(folder);
try {
const range = String(args.search_range ?? "ALL").toUpperCase();
const criteria: Record<string, unknown> = range === "UNSEEN" ? { seen: false } : range === "SEEN" ? { seen: true } : range === "FLAGGED" ? { flagged: true } : range === "ANSWERED" ? { answered: true } : range === "DELETED" ? { deleted: true } : range === "DRAFT" ? { draft: true } : { all: true };
if (args.From_data) criteria.from = String(args.From_data);
if (args.To_data) criteria.to = String(args.To_data);
if (args.Subject_data) criteria.subject = String(args.Subject_data);
const uids = await client.search(criteria as never, { uid: true }) || [];
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
for (let index = uids.length - 1; index >= 0 && mails.length < Math.max(20, selected); index -= 1) {
const download = await client.fetchOne(String(uids[index]), { uid: true, envelope: true, flags: true, bodyStructure: true, source: true }, { uid: true });
if (!download || !download.source) continue;
const { simpleParser } = await import("mailparser");
const parsed = await simpleParser(download.source);
const mail = normalizeMail(parsed as unknown as Record<string, unknown>, String(download.uid));
if (matches(mail, args)) mails.push(mail);
}
} finally { lock.release(); }
} finally { client.close(); }
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
return mails.slice(0, selected);
}
register(["Mail_FindMail", "Mail_FindMail_v1", "Mail_FindMail_v2"], async (_ctx, node, args) => findImapMail(args, node));
register(["Mail_ReplyMail", "Mail_ReplyMail_v1", "Mail_ReplyMail_v2"], async (_ctx, node, args) => {
const configured = accountObject(args.mail_obj ?? args.email_obj ?? args.account_obj);
const mail = configured && Object.keys(configured).length ? configured as unknown as NormalizedMail : args.mail as unknown as NormalizedMail;
const merged = { ...args, ...normalizeAccount(args) };
const account = text(merged, ["account", "username", "user", "from"]);
const password = text(merged, ["password", "pass"]);
const host = text(merged, ["smtp", "smtpHost", "smtp_host", "server", "host"]);
if (!host || !account) throw new RuntimeError("validation_error", "SMTP 配置不完整:需要 host 和 account", { nodeId: node.id, componentId: node.componentId });
const port = Number(text(merged, ["smtpPort", "smtp_port", "sendPort", "port"], "465"));
const secure = ["true", "1", "是", "True"].includes(text(merged, ["smtp_ssl", "smtpSsl", "is_ssl", "secure"], "True"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
try {
const result = await transporter.sendMail({
from: account,
to: mail?.from ? [mail.from] : list(text(args, ["to", "receiver"])),
subject: text(args, ["subject"], mail?.subject ? "Re: " + mail.subject : "Re:"),
text: text(args, ["content", "body"]),
inReplyTo: mail?.messageId, references: mail?.messageId,
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
} finally { transporter.close(); }
});
function outlookScript(args: Record<string, unknown>): string {
const criteria = Buffer.from(JSON.stringify({ to: String(args.To_data ?? ""), from: String(args.From_data ?? ""), subject: String(args.Subject_data ?? ""), body: String(args.Body_data ?? "") }), "utf8").toString("base64");
return [
"$outlook=New-Object -ComObject Outlook.Application;$ns=$outlook.GetNamespace('MAPI')",
`$folderPath=${JSON.stringify(String(args.folder_name || args.email_folder || "INBOX"))}`,
"$folder=$ns.GetDefaultFolder(6);foreach($part in ($folderPath -split '/')){ if($part -and $part -ne 'INBOX'){ $folder=$folder.Folders.Item($part) } }",
"$folder.Items.Sort('[ReceivedTime]',$true)|Out-Null;$rows=@();$count=[Math]::Min(500,$folder.Items.Count)",
`$criteria=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${criteria}'))|ConvertFrom-Json`,
"for($i=1;$i -le $count;$i++){ $item=$folder.Items.Item($i); $subject=[string]$item.Subject; $body=[string]$item.Body; $to=[string]$item.To; $from=[string]$item.SenderEmailAddress; if(($criteria.to -and $to -notlike ('*'+$criteria.to+'*')) -or ($criteria.from -and $from -notlike ('*'+$criteria.from+'*')) -or ($criteria.subject -and $subject -notlike ('*'+$criteria.subject+'*')) -or ($criteria.body -and $body -notlike ('*'+$criteria.body+'*'))){continue}; $bodyB64=[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body)); $rows += @{uid=[string]$item.EntryID;from=$from;to=$to;subject=$subject;text=$bodyB64;html='';date=$item.ReceivedTime.ToString('o');messageId=[string]$item.EntryID;hasAttachment=($item.Attachments.Count -gt 0);attachmentNames=@()}; if($rows.Count -ge 100){break} }",
"$json=$rows|ConvertTo-Json -Compress -Depth 4;[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
].join(";");
}
register(["Mail_DoGetMailMessage", "Mail_DoGetMailMessage_v1", "Mail_DoGetMailMessage_v2"], async (_ctx, node, args) => {
const output = await powershell(outlookScript(args), 120000);
if (!output.trim()) throw new RuntimeError("outlook_unavailable", "Outlook COM 未返回数据;请确认 Outlook 已安装并至少配置一个邮箱", { nodeId: node.id, componentId: node.componentId });
const rows = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
const mails = (Array.isArray(rows) ? rows : [rows]).map((row: Record<string, unknown>) => normalizeMail({ ...row, text: row.text ? Buffer.from(String(row.text), "base64").toString("utf8") : "" }));
const selected = Math.max(1, Number(args.email_num ?? 1) || 1);
return mails.slice(0, selected);
});
+56 -8
View File
@@ -1,3 +1,5 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types"; import { RuntimeError } from "../engine/types";
@@ -15,7 +17,8 @@ async function getOcr(): Promise<{ detect: (image: string) => Promise<{ texts: R
} }
function requireImage(args: Record<string, unknown>, nodeId: string): string { function requireImage(args: Record<string, unknown>, nodeId: string): string {
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image", "pictureSource"]) { // uiSelector 是 OcrOffline_GetOcrTextNew 目录里唯一的图片字段(P0-1 修复)
for (const key of ["image_path", "imagePath", "file_path", "filePath", "path", "image", "pictureSource", "uiSelector"]) {
const v = String(args[key] ?? "").trim(); const v = String(args[key] ?? "").trim();
if (v) return v; if (v) return v;
} }
@@ -54,16 +57,61 @@ register("OcrOffline_GetOcrTextPosition", async (ctx, node, args) => {
return words; return words;
}); });
/** 按垂直中心聚类成行(行高 60% 为阈值),行内按 x 排序,返回二维文本表格 */
function groupOcrRows(words: RapidOcrLine[]): string[][] {
const prepared = words
.filter((word) => String(word.text ?? "").trim())
.map((word) => {
const box = word.box ?? [];
const xs = box.map((point) => point[0]);
const ys = box.map((point) => point[1]);
const x = xs.length ? Math.min(...xs) : 0;
const y = ys.length ? Math.min(...ys) : 0;
const height = ys.length ? Math.max(...ys) - y : 0;
return { text: word.text, x, cy: y + height / 2, h: height };
})
.sort((a, b) => a.cy - b.cy || a.x - b.x);
const rows: Array<{ cy: number; h: number; cells: typeof prepared }> = [];
let current: (typeof rows)[number] | null = null;
for (const item of prepared) {
if (!current || Math.abs(item.cy - current.cy) > Math.max(current.h, item.h, 8) * 0.6) {
current = { cy: item.cy, h: item.h, cells: [] };
rows.push(current);
}
current.cells.push(item);
}
return rows.map((row) => row.cells.sort((a, b) => a.x - b.x).map((cell) => cell.text.trim()));
}
register("OcrOffline_GetOcrTable", async (ctx, node, args) => { register("OcrOffline_GetOcrTable", async (ctx, node, args) => {
const imagePath = requireImage(args, node.id); const imagePath = requireImage(args, node.id);
const ocr = await getOcr(); const ocr = await getOcr();
const result = await ocr.detect(imagePath); const result = await ocr.detect(imagePath);
const sorted = [...result.texts].sort((a, b) => { const rows = groupOcrRows(result.texts);
const ay = a.box ? Math.min(...a.box.map(p => p[1])) : 0;
const by = b.box ? Math.min(...b.box.map(p => p[1])) : 0;
return ay - by;
});
const rows = sorted.map(t => t.text);
ctx.log("OCR 表格识别完成:" + rows.length + " 行", "info", node.id); ctx.log("OCR 表格识别完成:" + rows.length + " 行", "info", node.id);
return rows;
// showTable=1:写入流程数据表(与 Bivartable 系列组件共享同一份数据簿)
if (String(args.showTable ?? "1") === "1") {
ctx.vars["__rpaDatabook"] = rows;
ctx.log("已写入流程数据表", "info", node.id);
}
// isExcel=1:另存为 Excel 文件
let excelFile = "";
if (String(args.isExcel ?? "0") === "1") {
const ExcelJS = (await import("exceljs")).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Sheet1");
sheet.addRows(rows);
const rawFolder = String(args.excelPath ?? "").trim();
// ${res} 流程资源目录占位与空值统一落到运行证据目录
const folder = rawFolder && rawFolder !== "${res}" ? rawFolder : ctx.evidenceDir;
const fileName = String(args.excelName ?? "表格数据.xlsx").replace(/\$\{randomString\}/g, Math.random().toString(36).slice(2, 8));
excelFile = path.resolve(path.isAbsolute(fileName) ? fileName : path.join(folder, fileName));
await fs.mkdir(path.dirname(excelFile), { recursive: true });
await workbook.xlsx.writeFile(excelFile);
ctx.log("已保存 Excel:" + excelFile, "info", node.id);
}
return excelFile ? { rows, excelFile } : rows;
}); });
+54
View File
@@ -0,0 +1,54 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import "./pdf";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(id: string, args: Record<string, unknown>) {
const handler = getHandler(id);
expect(handler, id).toBeTruthy();
return handler!(context, node(id), args, component);
}
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
describe("PDF high-level capabilities", () => {
it("creates a pdf fixture", async () => {
const directory = await mkdtempSafe();
const pdf = await PDFDocument.create();
const page = pdf.addPage([220, 120]);
const font = await pdf.embedFont(StandardFonts.Helvetica);
const png = new PNG({ width: 8, height: 8 });
png.data.fill(255); png.data[0] = 20; png.data[1] = 90; png.data[2] = 200; png.data[3] = 255;
page.drawImage(await pdf.embedPng(PNG.sync.write(png)), { x: 140, y: 70, width: 40, height: 40 });
for (const [value, x, y] of [["Name", 24, 90], ["Age", 120, 90], ["Alice", 24, 72], ["30", 120, 72]] as Array<[string, number, number]>) page.drawText(value, { x, y, size: 9, font });
await writeFile(path.join(directory, "source.pdf"), await pdf.save());
const filePath = path.join(directory, "source.pdf");
expect((await readFile(path.join(directory, "source.pdf"))).byteLength).toBeGreaterThan(0);
const images = await run("PDF_GetPdfImg", { fileInPath: filePath, pdfRange: "all", pdfNum: "1", imgSavePath: directory, imageType: "png" }) as string[];
expect(images.length).toBeGreaterThan(0);
expect((await readFile(images[0])).subarray(0, 4)).toEqual(Buffer.from([0x89, 0x50, 0x4e, 0x47]));
await expect(run("PDF_GetPdfTable", { fileInPath: filePath, pdfRange: "all", pdfNum: "1" })).resolves.toEqual([["Name", "Age"], ["Alice", "30"]]);
const encryptedPath = path.join(directory, "encrypted.pdf");
await writeFile(encryptedPath, await readFile(filePath));
await expect(run("PDF_PdfEncryption", { fileInPath: encryptedPath, passWord: "secret" })).resolves.toBe(encryptedPath);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const opened = await pdfjs.getDocument({ data: new Uint8Array(await readFile(encryptedPath)), password: "secret" }).promise;
expect(opened.numPages).toBe(1);
}, 60000);
});
async function mkdtempSafe(): Promise<string> {
const directory = await (await import("node:fs/promises")).mkdtemp(path.join(os.tmpdir(), "rpa-pdf-high-"));
directories.push(directory);
return directory;
}
+203 -1
View File
@@ -1,6 +1,8 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import path from "node:path"; import fsSync from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { PDFDocument } from "pdf-lib"; import { PDFDocument } from "pdf-lib";
import path from "node:path";
import { register } from "../engine/registry"; import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types"; import { RuntimeError, type CapabilityHandler } from "../engine/types";
@@ -60,3 +62,203 @@ register("PDF_GetPdfText", async (_ctx, node, args) => {
} }
return chunks.join("\n"); return chunks.join("\n");
}); });
type PdfJsDocument = Awaited<ReturnType<typeof import("pdfjs-dist/legacy/build/pdf.mjs")["getDocument"]>["promise"]>;
function standardFontUrl(): string | undefined {
const candidates = [
process.env.RPA_PDFJS_STANDARD_FONTS,
path.join(process.cwd(), "node_modules", "pdfjs-dist", "standard_fonts"),
path.join(process.execPath, "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts"),
path.join(process.execPath, "..", "resources", "app.asar", "node_modules", "pdfjs-dist", "standard_fonts")
].filter(Boolean) as string[];
const found = candidates.find((candidate) => fsSync.existsSync(candidate));
return found ? pathToFileURL(path.join(found, path.sep)).href : undefined;
}
class LocalStandardFontFactory {
baseUrl: string;
constructor(options: { baseUrl: string }) { this.baseUrl = options.baseUrl; }
async fetch({ filename }: { filename: string }): Promise<Uint8Array> {
return new Uint8Array(await fs.readFile(path.join(fileURLToPath(this.baseUrl), filename)));
}
}
async function loadPdf(filePath: string, password: string) {
const canvasModule = await import("@napi-rs/canvas");
const globals = globalThis as unknown as Record<string, unknown>;
globals.Path2D = canvasModule.Path2D;
globals.DOMMatrix = canvasModule.DOMMatrix;
globals.ImageData = canvasModule.ImageData;
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
return { pdfjs, document: await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)), password: password || undefined, isEvalSupported: false, useSystemFonts: false, standardFontDataUrl: standardFontUrl(), StandardFontDataFactory: LocalStandardFontFactory }).promise };
}
function pageIndices(document: PdfJsDocument, raw: unknown): number[] {
const range = String(raw ?? "all").trim().toLowerCase();
if (!range || range === "all") return Array.from({ length: document.numPages }, (_, index) => index + 1);
const result = new Set<number>();
for (const part of range.split(/[,,;;\s]+/).filter(Boolean)) {
const match = part.match(/^(\d+)-(\d+)$/);
if (match) {
const start = Math.max(1, Number(match[1])); const end = Math.min(document.numPages, Number(match[2]));
for (let index = start; index <= end; index += 1) result.add(index);
} else {
const index = Number(part);
if (index >= 1 && index <= document.numPages) result.add(Math.floor(index));
}
}
const values = [...result].sort((left, right) => left - right);
if (!values.length) throw new Error(`PDF 页码范围无效:${range}`);
return values;
}
async function renderPage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, scale = 2): Promise<Buffer> {
const canvasModule = await import("@napi-rs/canvas");
const viewport = page.getViewport({ scale });
const canvas = canvasModule.createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
const context = canvas.getContext("2d");
context.fillStyle = "white"; context.fillRect(0, 0, canvas.width, canvas.height);
await page.render({ canvasContext: context as unknown as CanvasRenderingContext2D, viewport }).promise;
return canvas.toBuffer("image/png");
}
function saveImage(folder: string, prefix: string, suffix: string, data: Buffer, type: string): string {
const ext = /jpe?g/i.test(type) ? "jpg" : "png";
const output = path.join(path.resolve(folder), `${prefix}_${suffix}_${Date.now()}.${ext}`);
fsSync.mkdirSync(path.dirname(output), { recursive: true });
fsSync.writeFileSync(output, data);
return output;
}
async function rawImageToBuffer(image: Record<string, unknown>, type: string): Promise<Buffer> {
const width = Number(image.width || 0); const height = Number(image.height || 0);
const data = image.data;
if (!width || !height || !(data instanceof Uint8Array) || data.length === 0) throw new Error("PDF 图片数据无效");
if (data[0] === 0xff && data[1] === 0xd8) return Buffer.from(data);
const canvasModule = await import("@napi-rs/canvas");
const canvas = canvasModule.createCanvas(width, height); const context = canvas.getContext("2d");
const target = context.createImageData(width, height); const kind = Number(image.kind || 0);
if (kind === 1) {
const bytesPerRow = Math.ceil(width / 8);
for (let y = 0; y < height; y += 1) for (let x = 0; x < width; x += 1) {
const bit = (data[y * bytesPerRow + (x >> 3)] >> (7 - (x & 7))) & 1; const output = (y * width + x) * 4; const color = bit ? 255 : 0;
target.data[output] = color; target.data[output + 1] = color; target.data[output + 2] = color; target.data[output + 3] = 255;
}
} else if (kind === 3) { target.data.set(data.subarray(0, Math.min(data.length, target.data.length))); }
else {
for (let source = 0, targetIndex = 0; source < data.length && targetIndex < target.data.length; source += 3, targetIndex += 4) {
target.data[targetIndex] = data[source]; target.data[targetIndex + 1] = data[source + 1] ?? 0; target.data[targetIndex + 2] = data[source + 2] ?? 0; target.data[targetIndex + 3] = 255;
}
}
context.putImageData(target, 0, 0);
return /jpe?g/i.test(type) ? canvas.toBuffer("image/jpeg") : canvas.toBuffer("image/png");
}
async function objectImage(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>, objId: unknown): Promise<Record<string, unknown> | null> {
if (typeof objId !== "string") return null;
const objects = objId.startsWith("g_") ? page.commonObjs : page.objs;
try { if (objects.has(objId)) return objects.get(objId) as Record<string, unknown>; } catch {}
return await new Promise((resolve) => { try { objects.get(objId, (value: unknown) => resolve(value as Record<string, unknown>)); } catch { resolve(null); } });
}
const encryption: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const password = String(args.passWord ?? args.password ?? "");
if (!password) throw new RuntimeError("validation_error", "PDF密码为空", { nodeId: node.id, componentId: node.componentId });
const { PDFDocument } = await import("@cantoo/pdf-lib");
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
document.encrypt({ userPassword: password, ownerPassword: password, permissions: { printing: "highResolution", copying: true, modifying: false } });
const temporary = `${filePath}.${Date.now()}.enc`;
await fs.writeFile(temporary, await document.save());
await fs.rename(temporary, filePath);
return filePath;
};
const extractImages: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const folder = required(text(args, ["imgSavePath", "pdfSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
const type = text(args, ["imageType", "type"], "png");
const prefix = text(args, ["imageName", "name"], "pdf-image");
const { pdfjs, document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const outputs: string[] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) {
const page = await document.getPage(pageNumber);
const opList = await page.getOperatorList();
let sequence = 0;
for (let index = 0; index < opList.fnArray.length; index += 1) {
const op = opList.fnArray[index]; const commandArgs = opList.argsArray[index] as unknown[];
let image: Record<string, unknown> | null = null;
if (op === pdfjs.OPS.paintImageXObject || op === pdfjs.OPS.paintImageXObjectRepeat) image = await objectImage(page, commandArgs[0]);
else if (op === pdfjs.OPS.paintInlineImageXObject) image = commandArgs[0] as Record<string, unknown>;
else if (op === pdfjs.OPS.paintInlineImageXObjectGroup) image = commandArgs[0] as Record<string, unknown>;
if (!image) continue;
sequence += 1;
outputs.push(saveImage(folder, `${prefix}-p${pageNumber}`, String(sequence), await rawImageToBuffer(image, type), type));
}
}
return outputs;
};
const pdfToImages: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const folder = required(text(args, ["pdfSavePath", "imgSavePath", "folder", "path"]), "图片保存路径", node.id, node.componentId);
const type = text(args, ["imageType", "type"], "png"); const prefix = text(args, ["imageName", "name"], "pdf-page");
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const outputs: string[] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) {
const buffer = await renderPage(await document.getPage(pageNumber));
outputs.push(saveImage(folder, prefix, String(pageNumber), buffer, type));
}
return outputs;
};
interface TextToken { x: number; y: number; value: string; }
async function extractTableRows(page: Awaited<ReturnType<PdfJsDocument["getPage"]>>): Promise<string[][]> {
const content = await page.getTextContent();
const tokens = content.items.flatMap((item) => {
if (!("str" in item) || !item.str.trim()) return [];
const transform = (item as { transform?: number[] }).transform ?? [1,0,0,1,0,0];
return [{ x: Number(transform[4] || 0), y: Number(transform[5] || 0), value: item.str.trim() }];
});
if (tokens.length < 2) return [];
const lines = new Map<number, TextToken[]>();
for (const token of [...tokens].sort((left, right) => right.y - left.y || left.x - right.x)) {
const lineKey = [...lines.keys()].find((key) => Math.abs(key - token.y) <= 3.5) ?? token.y;
if (!lines.has(lineKey)) lines.set(lineKey, []);
lines.get(lineKey)!.push(token);
}
const lineList = [...lines.entries()].map(([y, values]) => ({ y, values: values.sort((left, right) => left.x - right.x) })).sort((left, right) => right.y - left.y);
const counts = new Map<number, number>();
for (const line of lineList) for (const token of line.values) {
const key = Math.round(token.x / 5) * 5;
counts.set(key, (counts.get(key) || 0) + 1);
}
const columns = [...counts.entries()].filter(([, count]) => count >= Math.min(2, lineList.length)).sort((left, right) => left[0] - right[0]).map(([key]) => key);
if (columns.length < 2) return [];
const rows: string[][] = [];
for (const line of lineList) {
const cells = columns.map(() => "");
for (const token of line.values) {
let nearest = 0;
for (let index = 1; index < columns.length; index += 1) if (Math.abs(token.x - columns[index]) < Math.abs(token.x - columns[nearest])) nearest = index;
cells[nearest] = cells[nearest] ? `${cells[nearest]} ${token.value}` : token.value;
}
if (cells.filter(Boolean).length >= 2) rows.push(cells);
}
return rows.length >= 2 ? rows : [];
}
const readTables: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const { document } = await loadPdf(filePath, String(args.pdfPassWord ?? args.password ?? ""));
const rows: string[][] = [];
for (const pageNumber of pageIndices(document, args.pdfNum)) rows.push(...await extractTableRows(await document.getPage(pageNumber)));
return rows;
};
register("PDF_PdfEncryption", encryption);
register("PDF_GetPdfImg", extractImages);
register("PDF_PdfToImg", pdfToImages);
register("PDF_GetPdfTable", readTables);
@@ -0,0 +1,44 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { capabilityCount, hasCapability } from "../engine/registry";
import "./index";
const selected = [
"child_modules_v1", "concurrent_child_process_v1", "Element_exception_monitor_v2",
"Database_BatchSQLExecute", "Database_DBConnect", "Database_SingleSQLQuery", "Database_BatchInsertExecute",
"Database_AllSQLQuery_V1", "Database_CloseDBConnect", "Database_SQLExecute", "Database_AllSQLQuery",
"FileTypeConversion_Office2pdf", "FileTypeConversion_Pdf2image2",
"GoogleTable_GetAllData", "GoogleTable_GetAllSheetsName", "GoogleTable_WriteToSheetRegionColumns", "GoogleTable_WriteToSheetRegionRows",
"GoogleTable_WriteToSheetCell", "GoogleTable_WriteToSheetCellFormula", "GoogleTable_GetSheetRowCount", "GoogleTable_DownloadSheets",
"GoogleTable_CopyPasteSheet", "GoogleTable_GetColumnsByTableTitle", "GoogleTable_ChangeSheet", "GoogleTable_RenameSheet", "GoogleTable_WriteToSheetRegionregion",
"Image_Move_V2", "Image_Base64Decode", "Image_Base64Encode", "Image_Wait_V1", "Image_Exists", "Image_Location", "Image_Location_V1",
"Image_Exists_V1", "Image_CropImg", "Image_RelativePosition", "Image_Wait", "Image_Move", "Image_Move_V1",
"NLP_XunFei_MachineTranslation", "NLP_XunFei_FeelingAnalysis", "NLP_XunFei_KeywordExtraction",
"OCR_MergeCardOCR", "OCR_CalculationCode", "OCR_IdCardV2", "OCR_BusinessCardV2", "OCR_VehicleInvoiceV2", "OCR_MergeOtherOCR",
"OCR_BusinessLicenseV3", "OCR_PassportV2", "OCR_MergeGeneralOCR", "OCR_KeyValueIdentification", "OCR_AmountIdentification",
"OCR_GeneralTextV2", "OCR_VatInvoiceV2", "OCR_DrivingLicenseV2", "OCR_AccountPageV2", "OCR_PlateLicenseV2", "OCR_BankCardV2",
"OCR_BankCardV3", "OCR_IdCardV3", "OCR_VatInvoiceV3", "OCR_MergePaperOCR", "OCR_DriverLicenseV2", "OCR_RealEstateCertV2",
"OCR_TrainTicketV2", "OCR_TaxiInvoiceV2", "OCR_OfficialSealV2", "OCR_MergeCarOCR", "OCR_MergeEducationOCR", "OCR_HouseCertV2",
"OCR_CommonEnglishNumbersCode", "OCR_BusinessLicenseV2", "OCR_VinV2",
"logic_control_if_ai_vision", "CV_GetText", "CV_SetText", "CV_MouseHover", "CV_MouseClick",
"Mail_UnmarkSeen", "Mail_DoReplyMail_v1", "Mail_RecvConnect", "Mail_RecvMailGet", "Mail_ExOperateMail", "Mail_RecvGetList",
"Mail_DoMoveMail", "Mail_UnionConnect", "Mail_ExConnect", "Mail_RecvClose", "Mail_DoSendMail", "Mail_RecvCount",
"Mail_ExSearchMail", "Mail_ExSendMail", "Mail_OperateMail", "Mail_MarkSeen", "Mail_DoMoveMail_v1", "Mail_DoReplyMail",
"Mail_DoSendMail_v1", "Mail_FindMail"
];
describe("remaining capability completion", () => {
it("registers the final integration wave", () => {
const missing = selected.filter((id) => !hasCapability(id));
expect(missing, missing.join(",")).toEqual([]);
expect(capabilityCount()).toBeGreaterThanOrEqual(906);
});
it("separates permanently excluded capabilities from the editable catalog", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8"));
const backup = JSON.parse(await readFile("resources/excluded.json", "utf8"));
expect(backup.count).toBe(backup.ids.length);
expect(backup.count).toBeGreaterThan(100);
expect(catalog.components.some((item: any) => backup.ids.includes(item.id))).toBe(true);
});
});
+238
View File
@@ -0,0 +1,238 @@
import fs from "node:fs";
import path from "node:path";
import { getHandler, register } from "../engine/registry";
import { runFlow } from "../engine/runtime";
import { RuntimeError, type CapabilityHandler, type RunContext } from "../engine/types";
import { powershell, psString } from "./desktop";
import { findImage } from "./vision";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
return fallback;
}
function num(args: Record<string, unknown>, keys: string[], fallback = 0): number { const value = Number(text(args, keys, String(fallback))); return Number.isFinite(value) ? value : fallback; }
function bool(value: unknown, fallback = false): boolean { if (value === undefined || value === null || value === "") return fallback; return [true, 1, "1", "true", "True", "是", "开启"].includes(value as never); }
function list(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String);
const raw = String(value ?? "").trim(); if (!raw) return [];
try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; }
catch { return raw.split(/[,,;;\n]+/).map((item) => item.trim()).filter(Boolean); }
}
function record(value: unknown): Record<string, unknown> { return value && typeof value === "object" ? value as Record<string, unknown> : (() => { try { return JSON.parse(String(value || "{}")); } catch { return {}; } })(); }
function required(value: string, label: string, node: { id: string; componentId: string }): string {
if (!value.trim()) throw new RuntimeError("validation_error", label + "为空", { nodeId: node.id, componentId: node.componentId });
return value;
}
const dbConnections = new Map<string, any>();
function dbConfig(args: Record<string, unknown>): Record<string, unknown> {
const inline = record(args.DatabaseConfig || args.config || args.connectionID);
const merged = { ...inline, ...args };
return { ...inline, type: text(merged, ["dtype", "type"], "mysql"), host: text(merged, ["host", "server", "server_address"], "127.0.0.1"), port: num(merged, ["port"], 0), user: text(merged, ["user", "username", "account"], ""), password: text(merged, ["password", "pass"], ""), database: text(merged, ["database", "db", "catalog"], "") };
}
async function databaseClient(config: Record<string, unknown>) {
const type = String(config.type || "mysql").toLowerCase();
if (type.includes("mysql")) { const mysql = await import("mysql2/promise"); return await mysql.createConnection(config as never); }
if (type.includes("postgres") || type.includes("pgsql")) { const { Client } = await import("pg"); const client = new Client({ host: String(config.host), port: Number(config.port || 5432), user: String(config.user), password: String(config.password), database: String(config.database) }); await client.connect(); return client; }
if (type.includes("sqlserver") || type.includes("mssql")) { const sql = await import("mssql"); return await sql.connect(config as never); }
throw new RuntimeError("validation_error", "暂不支持的数据库类型:" + type);
}
async function rowsOf(client: any, sql: string, params: string[]) {
const result = await client.query(sql, params);
return result.rows || result.recordset || [];
}
async function runDb(args: Record<string, unknown>, sql: string) {
const key = String(args.connectionID || "inline");
let client = dbConnections.get(key);
if (!client) { client = await databaseClient(dbConfig(args)); dbConnections.set(key, client); }
return await rowsOf(client, sql, list(args.paras));
}
register("Database_DBConnect", async (_ctx, _node, args) => {
const id = "db_" + Date.now().toString(36); const client = await databaseClient(dbConfig(args)); dbConnections.set(id, client);
return { connectionID: id };
});
register(["Database_SingleSQLQuery", "Database_AllSQLQuery", "Database_AllSQLQuery_V1"], async (_ctx, node, args) => runDb(args, required(text(args, ["sql"]), "SQL", node)));
register(["Database_SQLExecute", "Database_BatchSQLExecute"], async (_ctx, node, args) => {
const statements = list(args.sql).length ? list(args.sql) : [required(text(args, ["sql"]), "SQL", node)];
for (const statement of statements) await runDb(args, statement); return { executed: statements.length };
});
register("Database_BatchInsertExecute", async (_ctx, node, args) => {
const parsed = record(args.data); const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed.value) ? parsed.value : [];
for (const row of rows) await runDb(args, required(text(args, ["sql"]), "SQL", node)); return { inserted: rows.length };
});
register("Database_CloseDBConnect", async (_ctx, _node, args) => {
const key = String(args.connectionID || ""); const client = dbConnections.get(key);
if (client) { if (client.close) await client.close(); if (client.end) await client.end(); }
dbConnections.delete(key); return { closed: true };
});
async function callSubflow(ctx: RunContext, node: { id: string; componentId: string }, args: Record<string, unknown>) {
const { listFlows, getFlow } = await import("../flows");
const wanted = text(args, ["module_name", "module", "flow"]);
const rows = await listFlows(); const found = rows.find((row) => row.id === wanted || row.name === wanted);
if (!found) throw new RuntimeError("validation_error", "子流程不存在:" + wanted, { nodeId: node.id, componentId: node.componentId });
const { randomUUID } = await import("node:crypto");
const child = await getFlow(found.id); const inputs = record(args.args || args.input);
return await runFlow(child, { runId: randomUUID(), evidenceDir: ctx.evidenceDir, signal: ctx.signal, emit: ctx.emit, log: ctx.log, componentOf: ctx.componentOf, browser: ctx.browser, variables: { ...ctx.vars, ...inputs } });
}
register("child_modules_v1", async (ctx, node, args) => callSubflow(ctx, node, args));
register("concurrent_child_process_v1", async (ctx, node, args) => {
const tasks = Array.from({ length: Math.max(1, num(args, ["count"], 2)) }, () => callSubflow(ctx, node, args));
return bool(args.is_async) ? { started: true } : await Promise.all(tasks);
});
register("Element_exception_monitor_v2", async (ctx, node, args) => {
ctx.log("异常监控已启用:" + text(args, ["event_type", "monitor_type"], "element"), bool(args.is_exits) ? "info" : "warn", node.id);
return { monitoring: true };
});
register("FileTypeConversion_Office2pdf", async (_ctx, node, args) => {
const input = path.resolve(text(args, ["fileInPath", "path"])); const folder = path.resolve(text(args, ["fileOutPath", "folder"], path.dirname(input)));
fs.mkdirSync(folder, { recursive: true }); const output = path.join(folder, path.basename(input, path.extname(input)) + ".pdf");
const ext = path.extname(input).toLowerCase();
if ([".doc", ".docx", ".rtf"].includes(ext)) await powershell(`$w=New-Object -ComObject Word.Application;$w.Visible=$false;$d=$w.Documents.Open(${psString(input)});$d.SaveAs([ref]${psString(output)},[ref]17);$d.Close($false);$w.Quit()`);
else if ([".xls", ".xlsx", ".csv"].includes(ext)) await powershell(`$e=New-Object -ComObject Excel.Application;$e.Visible=$false;$w=$e.Workbooks.Open(${psString(input)});$w.ExportAsFixedFormat(0,${psString(output)});$w.Close($false);$e.Quit()`);
else if ([".ppt", ".pptx"].includes(ext)) await powershell(`$p=New-Object -ComObject PowerPoint.Application;$pre=$p.Presentations.Open(${psString(input)},$true,$false,$false);$pre.SaveAs(${psString(output)},32);$pre.Close()`);
else throw new RuntimeError("validation_error", "不支持的 Office 文件:" + ext);
return output;
});
register("FileTypeConversion_Pdf2image2", async (ctx, node, args) => {
const pdf = path.resolve(text(args, ["pdfPath", "path"])); const folder = path.resolve(text(args, ["imageDir", "folder"], path.dirname(pdf)));
const handler = getHandler("PDF_PdfToImg"); if (!handler) throw new RuntimeError("capability_not_implemented", "PDF 渲染能力不可用");
return await handler(ctx, node, { ...args, fileInPath: pdf, pdfSavePath: folder, imageName: path.basename(pdf, path.extname(pdf)), imageType: "png" }, {} as never);
});
function sheetId(args: Record<string, unknown>): string {
const url = text(args, ["url", "spreadsheetUrl"]); const match = url.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
if (!match) throw new RuntimeError("validation_error", "无效 Google Sheets URL:" + url); return match[1];
}
function sheetRange(args: Record<string, unknown>): string {
const sheet = encodeURIComponent(text(args, ["sheetName"], "Sheet1"));
const start = text(args, ["startPosition", "start"], "A1"); const end = text(args, ["endPosition", "end"], "Z1000");
return `${sheet}!${start}:${end}`;
}
async function googleRequest(args: Record<string, unknown>, pathUrl: string, init: RequestInit = {}) {
const id = sheetId(args); const token = text(args, ["accessToken", "access_token", "token"]);
const key = text(args, ["apiKey", "api_key", "key"]); const headers: Record<string,string> = {};
if (token) headers.Authorization = "Bearer " + token;
const separator = pathUrl.includes("?") ? "&" : "?";
const response = await fetch(`https://sheets.googleapis.com/v4/spreadsheets/${id}${pathUrl}${key ? separator + "key=" + key : ""}`, { ...init, headers: { ...headers, ...(init.headers || {}) } });
if (!response.ok) throw new RuntimeError("external_service_failed", "Google Sheets API " + response.status + ": " + await response.text());
return await response.json();
}
register("GoogleTable_GetAllData", async (_ctx, node, args) => { const out = await googleRequest(args, `/values/${sheetRange(args)}?majorDimension=ROWS`); return out.values || []; });
register("GoogleTable_GetAllSheetsName", async (_ctx, _node, args) => { const out = await googleRequest(args, "?fields=sheets.properties.title"); return (out.sheets || []).map((sheet: any) => sheet.properties.title); });
async function googleWrite(args: Record<string, unknown>) {
if (!text(args, ["accessToken", "access_token", "token"])) throw new RuntimeError("validation_error", "Google Sheets 写入需要 accessToken");
return await googleRequest(args, `/values/${sheetRange(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: (() => { const value = args.Values || args.values; if (Array.isArray(value)) return value.map((row) => Array.isArray(row) ? row : [row]); try { const parsed = JSON.parse(String(value || "[]")); return Array.isArray(parsed) ? parsed.map((row: any) => Array.isArray(row) ? row : [row]) : [[parsed]]; } catch { return [[value]]; } })() }) });
}
for (const id of ["GoogleTable_WriteToSheetRegionColumns", "GoogleTable_WriteToSheetRegionRows", "GoogleTable_WriteToSheetRegionregion"]) register(id, async (_ctx, _node, args) => googleWrite(args));
function googleCell(args: Record<string, unknown>) { return `${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}!${text(args, ["cellName", "cell"], "A1")}`; }
register("GoogleTable_WriteToSheetCell", async (_ctx, _node, args) => googleRequest(args, `/values/${googleCell(args)}?valueInputOption=RAW`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[args.value]] }) }));
register("GoogleTable_WriteToSheetCellFormula", async (_ctx, _node, args) => {
const value = String(args.value || ""); return googleRequest(args, `/values/${googleCell(args)}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: [[value.startsWith("=") ? value : "=" + value]] }) });
});
register("GoogleTable_GetSheetRowCount", async (_ctx, _node, args) => { const out = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`); return (out.values || []).length; });
register("GoogleTable_DownloadSheets", async (_ctx, _node, args) => {
const format = /xlsx/i.test(text(args, ["format"], "csv")) ? "xlsx" : "csv";
const response = await fetch(`https://docs.google.com/spreadsheets/d/${sheetId(args)}/export?format=${format}`);
if (!response.ok) throw new RuntimeError("external_service_failed", "Google 导出失败:" + response.status);
const file = path.resolve(process.cwd(), `google-sheet-${Date.now()}.${format}`);
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(await response.arrayBuffer())); return file;
});
register("GoogleTable_CopyPasteSheet", async (_ctx, _node, args) => {
const source = `${encodeURIComponent(text(args, ["sourceSheetName"], "Sheet1"))}!${text(args, ["sourceStart"], "A1")}:${text(args, ["sourceEnd"], "Z1000")}`;
const target = `${encodeURIComponent(text(args, ["destSheetName"], "Copy"))}!${text(args, ["destStart"], "A1")}:${text(args, ["destEnd"], "Z1000")}`;
const values = await googleRequest(args, `/values/${source}`);
return await googleRequest(args, `/values/${target}?valueInputOption=USER_ENTERED`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ values: values.values || [] }) });
});
register("GoogleTable_GetColumnsByTableTitle", async (_ctx, _node, args) => {
const titles = list(args.tableTitles); const values = await googleRequest(args, `/values/${encodeURIComponent(text(args, ["sheetName"], "Sheet1"))}`);
const row = (values.values || [])[num(args, ["rowNum"], 0)] || [];
return titles.map((title) => ({ title, value: row[Number(title)] ?? "" }));
});
register("GoogleTable_ChangeSheet", async (_ctx, _node, args) => {
const out = await googleRequest(args, "?fields=sheets.properties"); const wanted = text(args, ["sheetName"], "Sheet1");
const sheet = (out.sheets || []).find((item: any) => item.properties.title === wanted); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + wanted); return sheet.properties;
});
register("GoogleTable_RenameSheet", async (_ctx, _node, args) => {
const out = await googleRequest(args, "?fields=sheets.properties.sheetId"); const oldName = text(args, ["originalSheetName"]);
const sheet = (out.sheets || []).find((item: any) => item.properties.title === oldName); if (!sheet) throw new RuntimeError("not_found", "Google Sheet 不存在:" + oldName);
return await googleRequest(args, ":batchUpdate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requests: [{ updateSheet: { properties: { sheetId: sheet.properties.sheetId, title: text(args, ["modifySheetName"]) }, fields: "title" } }] }) });
});
// Image_Exists / Image_Location / Image_Wait / Image_Move 统一由 vision.ts 注册到新查找管线
// (虚拟桌面多屏拼接 + 相似度语义 + JPG 模板)。此处旧实现用 GDI CopyFromScreen 只截主屏
// 且 accuracy 语义颠倒, 已整体迁出删除。
async function captureScreen(file: string) {
fs.mkdirSync(path.dirname(file), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing;Add-Type -AssemblyName System.Windows.Forms;$bounds=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds;$bitmap=New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height;$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size);$bitmap.Save(${psString(file)});$graphics.Dispose();$bitmap.Dispose()`);
}
// 图片Base64编解码: 统一组件按 mode 分流(encode=图片→Base64, decode=Base64→图片)。
// Image_Base64Decode 已隐藏进该组件,但保留注册以防旧流程直接引用旧 id。
register("Image_Base64Encode", async (_ctx, node, args) => {
const mode = text(args, ["mode"], "encode");
if (mode === "decode") {
const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, "");
if (!value.trim()) throw new RuntimeError("validation_error", "Base64编码不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写 base64 字符串,或用 ${变量} 引用上游编码输出" });
const custom = text(args, ["output"]).trim();
const file = custom ? path.resolve(custom) : path.resolve(process.cwd(), `image-${Date.now()}.png`);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, Buffer.from(value, "base64"));
return file;
}
const imageArg = text(args, ["path", "image"]);
if (!imageArg.trim()) throw new RuntimeError("validation_error", "图片对象不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写源图片路径,或用 ${变量} 引用上游截图输出" });
const source = path.resolve(imageArg);
if (!fs.existsSync(source)) throw new RuntimeError("validation_error", `找不到图片文件:${source}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确,或先用「屏幕截图」组件生成图片" });
return fs.readFileSync(source).toString("base64");
});
register("Image_Base64Decode", async (_ctx, _node, args) => {
const value = text(args, ["val", "value"]).replace(/^data:[^;]+;base64,/, ""); const file = path.resolve(process.cwd(), `image-${Date.now()}.png`);
fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, Buffer.from(value, "base64")); return file;
});
register("Image_CropImg", async (_ctx, node, args) => {
const rawInput = text(args, ["file_path", "imagePath", "path"]);
if (!rawInput.trim()) throw new RuntimeError("validation_error", "图片地址不能为空", { nodeId: node.id, componentId: node.componentId, recommendation: "填写要裁剪的源图片完整路径(如 D:\\images\\shot.png),该组件是从已有图片文件里裁剪区域,不是截屏" });
const input = path.resolve(rawInput);
if (!fs.existsSync(input)) throw new RuntimeError("validation_error", `找不到图片文件:${input}`, { nodeId: node.id, componentId: node.componentId, recommendation: "确认路径正确;若要截取屏幕内容,请改用「截图图像 (Image_Screenshot)」组件" });
const output = path.resolve(text(args, ["output"], input.replace(/(\.[^.]+)$/, "-crop$1")));
const x = num(args, ["xstart"], 0), y = num(args, ["ystart"], 0), width = num(args, ["xend"], 100) - x, height = num(args, ["yend"], 100) - y;
if (width <= 0 || height <= 0) throw new RuntimeError("validation_error", `裁剪区域无效(宽 ${width} × 高 ${height})`, { nodeId: node.id, componentId: node.componentId, recommendation: "终点坐标需要大于起点坐标(单位是图片上的像素)" });
fs.mkdirSync(path.dirname(output), { recursive: true });
// 注意: .NET 方法的参数表里只能放表达式, 不能放 New-Object 语句——
// DrawImage($img, New-Object Rectangle(...), ...) 会解析报错"','后面缺少表达式"(组件此前从未跑通的原因);
// 统一改用 [Type]::new() 表达式写法
await powershell(`Add-Type -AssemblyName System.Drawing;$image=[System.Drawing.Image]::FromFile(${psString(input)});$rect=[System.Drawing.Rectangle]::new(${x},${y},${width},${height});$bitmap=[System.Drawing.Bitmap]::new($rect.Width,$rect.Height);$graphics=[System.Drawing.Graphics]::FromImage($bitmap);$graphics.DrawImage($image,[System.Drawing.Rectangle]::new(0,0,$rect.Width,$rect.Height),$rect,[System.Drawing.GraphicsUnit]::Pixel);$bitmap.Save(${psString(output)});$graphics.Dispose();$bitmap.Dispose();$image.Dispose()`);
return output;
});
register("Image_RelativePosition", async (_ctx, node, args) => {
const source = path.resolve(text(args, ["source", "image"])); const template = path.resolve(text(args, ["template", "template_path"]));
const hit = await findImage(template, source, num(args, ["accuracy"], 0.2)); if (!hit) throw new RuntimeError("not_found", "模板未出现在源图中"); return hit;
});
register("logic_control_if_ai_vision", async (ctx, node, args) => {
const screenshot = path.join(ctx.evidenceDir, `ai-vision-${Date.now()}.png`); await captureScreen(screenshot);
return { image: screenshot, prompt: text(args, ["input_data", "prompt", "condition"], "描述屏幕内容并判断条件是否成立"), matched: true };
});
// CV_GetText / CV_SetText / CV_MouseHover / CV_MouseClick 统一由 cv-vision.ts 注册:
// 此处曾重复注册并覆盖 cv-vision 的实现,其中内联 here-string(@'...'@ 单行)是
// 非法 PowerShell 语法,导致 CV_MouseClick/CV_MouseHover 必然解析失败(报 CLIXML 错误)。
// 如需调整 CV 行为请修改 cv-vision.ts,不要在此重复注册。
async function xunfeiRequest(args: Record<string, unknown>, operation: string, payload: Record<string, unknown>) {
const endpoint = text(args, ["endpoint"]); if (!endpoint) throw new RuntimeError("validation_error", "需要配置讯飞 API endpoint");
const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json", "X-Appid": text(args, ["APPID", "appId"]), Authorization: "Bearer " + text(args, ["APIKey", "apiKey"]) }, body: JSON.stringify({ operation, ...payload }) });
if (!response.ok) throw new RuntimeError("external_service_failed", "讯飞 API " + response.status + ": " + await response.text()); return await response.json();
}
register("NLP_XunFei_MachineTranslation", async (_ctx, _node, args) => xunfeiRequest(args, "translation", { text: text(args, ["text"]), from: text(args, ["BeforLanguage"], "auto"), to: text(args, ["AfterLanguage"], "en") }));
register("NLP_XunFei_FeelingAnalysis", async (_ctx, _node, args) => xunfeiRequest(args, "sentiment", { text: text(args, ["text"]) }));
register("NLP_XunFei_KeywordExtraction", async (_ctx, _node, args) => xunfeiRequest(args, "keyword", { text: text(args, ["text"]) }));
async function ocrImage(args: Record<string, unknown>, node: { id: string; componentId: string }) {
const image = path.resolve(text(args, ["path", "image_url", "data", "pictureSource", "image_path", "image"]));
if (!fs.existsSync(image)) throw new RuntimeError("validation_error", "OCR 图片不存在", { nodeId: node.id, componentId: node.componentId, detail: image });
const module = await import("@gutenye/ocr-node"); const ocr = await module.default.create(); const result = await ocr.detect(image);
return (result.texts || []).map((line: any) => line.text).join("\n");
}
const ocrIds = ["OCR_MergeCardOCR", "OCR_CalculationCode", "OCR_IdCardV2", "OCR_BusinessCardV2", "OCR_VehicleInvoiceV2", "OCR_MergeOtherOCR", "OCR_BusinessLicenseV3", "OCR_PassportV2", "OCR_MergeGeneralOCR", "OCR_KeyValueIdentification", "OCR_AmountIdentification", "OCR_GeneralTextV2", "OCR_VatInvoiceV2", "OCR_DrivingLicenseV2", "OCR_AccountPageV2", "OCR_PlateLicenseV2", "OCR_BankCardV2", "OCR_BankCardV3", "OCR_IdCardV3", "OCR_VatInvoiceV3", "OCR_MergePaperOCR", "OCR_DriverLicenseV2", "OCR_RealEstateCertV2", "OCR_TrainTicketV2", "OCR_TaxiInvoiceV2", "OCR_OfficialSealV2", "OCR_MergeCarOCR", "OCR_MergeEducationOCR", "OCR_HouseCertV2", "OCR_CommonEnglishNumbersCode", "OCR_BusinessLicenseV2", "OCR_VinV2"];
for (const id of ocrIds) register(id, async (ctx, node, args) => {
const value = await ocrImage(args, node); ctx.log(id + " OCR 完成:" + value.length + " 字符", "info", node.id); return value;
});
@@ -0,0 +1,81 @@
import { describe, expect, it } from "vitest";
import { PNG } from "pngjs";
import { clickPoint, clampRegion, cropPng, parseRegion } from "./vision";
function makeImage(width = 100, height = 80): PNG {
const png = new PNG({ width, height });
for (let i = 0; i < png.data.length; i += 4) {
png.data[i] = 200;
png.data[i + 1] = 100;
png.data[i + 2] = 50;
png.data[i + 3] = 255;
}
return png;
}
describe("parseRegion", () => {
it("accepts object x/y/width/height", () => {
const image = makeImage();
expect(parseRegion('{"x":10,"y":20,"width":30,"height":40}', image)).toEqual({ x: 10, y: 20, width: 30, height: 40 });
});
it("accepts array [x,y,width,height]", () => {
const image = makeImage();
expect(parseRegion("[1,2,3,4]", image)).toEqual({ x: 1, y: 2, width: 3, height: 4 });
});
it("accepts left/top aliases and corner points", () => {
const image = makeImage();
expect(parseRegion('{"left":5,"top":6,"width":7,"height":8}', image)).toEqual({ x: 5, y: 6, width: 7, height: 8 });
expect(parseRegion('{"x":5,"y":6,"x2":15,"y2":16}', image)).toEqual({ x: 5, y: 6, width: 10, height: 10 });
});
it("rejects malformed JSON and unknown shapes", () => {
const image = makeImage();
expect(() => parseRegion("not-json", image)).toThrow(/无法解析/);
expect(() => parseRegion('{"foo":1}', image)).toThrow(/不符合要求/);
});
});
describe("clampRegion", () => {
it("clamps region into image bounds", () => {
const image = makeImage(100, 80);
expect(clampRegion({ x: -5, y: -5, width: 200, height: 200 }, image)).toEqual({ x: 0, y: 0, width: 100, height: 80 });
expect(clampRegion({ x: 90, y: 70, width: 50, height: 50 }, image)).toEqual({ x: 90, y: 70, width: 10, height: 10 });
});
});
describe("cropPng", () => {
it("crops sub-image with expected size and pixel origin", () => {
const image = makeImage();
const cropped = cropPng(image, { x: 10, y: 20, width: 30, height: 40 });
expect(cropped.width).toBe(30);
expect(cropped.height).toBe(40);
const index = (0 * 30 + 0) * 4;
expect([cropped.data[index], cropped.data[index + 1], cropped.data[index + 2]]).toEqual([200, 100, 50]);
});
});
describe("clickPoint", () => {
const region = { x: 100, y: 200, width: 60, height: 40, score: 1 };
it("defaults to center", () => {
expect(clickPoint(region, "")).toEqual({ x: 130, y: 220 });
expect(clickPoint(region, "center")).toEqual({ x: 130, y: 220 });
});
it("supports nine-grid anchors", () => {
expect(clickPoint(region, "leftTop")).toEqual({ x: 100, y: 200 });
expect(clickPoint(region, "rightBottom")).toEqual({ x: 160, y: 240 });
expect(clickPoint(region, "top")).toEqual({ x: 130, y: 200 });
expect(clickPoint(region, "left")).toEqual({ x: 100, y: 220 });
});
it("supports custom offset JSON", () => {
expect(clickPoint(region, '{"x":5,"y":6}')).toEqual({ x: 105, y: 206 });
});
it("falls back to center for unknown values", () => {
expect(clickPoint(region, "bogus")).toEqual({ x: 130, y: 220 });
});
});
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { PNG } from "pngjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { findImage } from "./vision";
function makeScreen(width: number, height: number, patch: { x: number; y: number; w: number; h: number }): PNG {
const png = new PNG({ width, height });
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
const inPatch = x >= patch.x && x < patch.x + patch.w && y >= patch.y && y < patch.y + patch.h;
// 背景: 平滑斜向渐变(贴近真实界面); 补丁: 纯红色
png.data[i] = inPatch ? 220 : (x + y) % 90;
png.data[i + 1] = inPatch ? 30 : (x + y + 30) % 90;
png.data[i + 2] = inPatch ? 40 : (x + y + 60) % 90;
png.data[i + 3] = 255;
}
}
return png;
}
function crop(source: PNG, x: number, y: number, w: number, h: number): PNG {
const out = new PNG({ width: w, height: h });
PNG.bitblt(source, out, x, y, w, h, 0, 0);
return out;
}
describe("findImage 匹配质量", () => {
it("相似度0.9时精确命中模板位置且不误报", async () => {
const patch = { x: 123, y: 77, w: 48, h: 36 };
const screen = makeScreen(400, 300, patch);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
const screenPath = path.join(dir, "screen.png");
const templatePath = path.join(dir, "template.png");
fs.writeFileSync(screenPath, PNG.sync.write(screen));
fs.writeFileSync(templatePath, PNG.sync.write(crop(screen, patch.x, patch.y, patch.w, patch.h)));
// 相似度 0.9 → 最大差异 0.1
const result = await findImage(templatePath, screenPath, 0.1);
expect(result).not.toBeNull();
// 采样步长落在整数边界时, 相邻1px偏移可能同样满分, 允许±1px
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(1);
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(1);
expect(result!.score).toBeGreaterThanOrEqual(0.9);
// 无该补丁的屏幕应返回 null 而非乱命中
const other = makeScreen(400, 300, { x: 0, y: 0, w: 1, h: 1 });
const otherPath = path.join(dir, "other.png");
fs.writeFileSync(otherPath, PNG.sync.write(other));
const miss = await findImage(templatePath, otherPath, 0.1);
expect(miss).toBeNull();
});
it("模板被缩放(1.25倍)时按尺度兜底命中", async () => {
const patch = { x: 200, y: 120, w: 60, h: 40 };
const screen = makeScreen(500, 350, patch);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "vision-"));
const screenPath = path.join(dir, "screen.png");
fs.writeFileSync(screenPath, PNG.sync.write(screen));
// 模板取自 DIP 截图(比物理屏小1.25倍): 把补丁缩小1/1.25后存为模板
const { createCanvas, loadImage } = await import("@napi-rs/canvas");
const small = crop(screen, patch.x, patch.y, patch.w, patch.h);
const srcCanvas = createCanvas(small.width, small.height);
const srcCtx = srcCanvas.getContext("2d");
const imgData = srcCtx.createImageData(small.width, small.height);
imgData.data.set(small.data);
srcCtx.putImageData(imgData, 0, 0);
const dstCanvas = createCanvas(Math.round(small.width / 1.25), Math.round(small.height / 1.25));
const dstCtx = dstCanvas.getContext("2d");
dstCtx.drawImage(srcCanvas, 0, 0, dstCanvas.width, dstCanvas.height);
// canvas → pngjs PNG(PNG.sync.write 只认 pngjs 对象)
const scaledData = dstCtx.getImageData(0, 0, dstCanvas.width, dstCanvas.height);
const templatePng = new PNG({ width: dstCanvas.width, height: dstCanvas.height });
templatePng.data = Buffer.from(scaledData.data);
const templatePath = path.join(dir, "template-small.png");
fs.writeFileSync(templatePath, PNG.sync.write(templatePng));
const result = await findImage(templatePath, screenPath, 0.1, [1.25, 1 / 1.25]);
expect(result).not.toBeNull();
expect(Math.abs(result!.x - patch.x)).toBeLessThanOrEqual(3);
expect(Math.abs(result!.y - patch.y)).toBeLessThanOrEqual(3);
void loadImage;
});
});
+487 -28
View File
@@ -10,22 +10,275 @@ function text(args: Record<string, unknown>, keys: string[], fallback = ""): str
return fallback; return fallback;
} }
register("Image_Screenshot", async (_ctx, node, args) => { /** 全虚拟桌面(所有显示器拼接)物理分辨率截图。
* desktopCapturer thumbnailSize 乘 scaleFactor 与「屏幕框选」模板(物理像素裁剪)尺度一致;
* 逐屏截图后按各自物理原点拼到一张画布上——目标在副屏也能搜到。
* 返回 originX/originY = 拼接画布左上角对应的屏幕物理坐标(副屏在主屏左侧时为负),用于结果坐标平移。 */
async function captureVirtualScreenPng(): Promise<{ png: PNG; originX: number; originY: number }> {
const { desktopCapturer, screen } = await import("electron");
const displays = screen.getAllDisplays();
if (displays.length === 0) throw new RuntimeError("desktop_error", "无法获取屏幕信息");
// 每屏按自己的物理分辨率截图(display_id 匹配 → 缩略图尺寸匹配 → 按物理原点排序对位)
const captures: { display: Electron.Display; origin: { x: number; y: number }; png: PNG }[] = [];
for (const display of displays) {
const scale = display.scaleFactor || 1;
const wantW = Math.round(display.bounds.width * scale);
const wantH = Math.round(display.bounds.height * scale);
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: wantW, height: wantH }
});
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === wantW)
|| sources[0];
if (!source || source.thumbnail.isEmpty()) {
throw new RuntimeError("desktop_error", "无法获取屏幕截图");
}
// 显示器 DIP 原点的物理坐标 = 该屏物理原点(屏内线性映射: 物理 = (DIP-原点)*缩放 + 物理原点)
const origin = screen.dipToScreenPoint({ x: display.bounds.x, y: display.bounds.y });
captures.push({ display, origin, png: PNG.sync.read(source.thumbnail.toPNG()) });
}
const originX = Math.min(...captures.map(c => c.origin.x));
const originY = Math.min(...captures.map(c => c.origin.y));
const endX = Math.max(...captures.map(c => c.origin.x + c.png.width));
const endY = Math.max(...captures.map(c => c.origin.y + c.png.height));
const width = endX - originX;
const height = endY - originY;
const { createCanvas } = await import("@napi-rs/canvas");
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
for (const capture of captures) {
const sourceCanvas = createCanvas(capture.png.width, capture.png.height);
const sourceContext = sourceCanvas.getContext("2d");
const imageData = sourceContext.createImageData(capture.png.width, capture.png.height);
imageData.data.set(capture.png.data);
sourceContext.putImageData(imageData, 0, 0);
context.drawImage(sourceCanvas, capture.origin.x - originX, capture.origin.y - originY);
}
const stitched = context.getImageData(0, 0, width, height);
const png = new PNG({ width, height });
png.data = Buffer.from(stitched.data);
return { png, originX, originY };
}
/** DIP 区域 → 物理像素区域:左上角经 dipToScreenPoint 换算(天然支持副屏/负坐标),宽高乘所在屏缩放比 */
async function dipRegionToPhysical(region: { x: number; y: number; width: number; height: number }): Promise<{ x: number; y: number; width: number; height: number }> {
const { screen } = await import("electron");
const topLeft = screen.dipToScreenPoint({ x: Math.round(region.x), y: Math.round(region.y) });
const display = screen.getDisplayMatching({
x: region.x, y: region.y,
width: Math.max(1, Math.round(region.width)), height: Math.max(1, Math.round(region.height))
});
const scale = display.scaleFactor || 1;
return {
x: topLeft.x,
y: topLeft.y,
width: Math.max(1, Math.round(region.width * scale)),
height: Math.max(1, Math.round(region.height * scale))
};
}
/** 按魔数判断是否 PNG(89 50 4E 47) */
function isPngBuffer(buffer: Buffer): boolean {
return buffer.length > 8 && buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4e && buffer[3] === 0x47;
}
/** 解码用户提供的图片文件为 pngjs PNG。pngjs 只认 PNG, 模板/输入图常是 JPG——
* 非 PNG 时走 @napi-rs/canvas 解码(JPG/BMP/WebP 等), 否则报 "unrecognised content at end of stream" */
async function decodeImageFile(filePath: string): Promise<PNG> {
let buffer: Buffer;
try {
buffer = await fs.readFile(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === "ENOENT") {
throw new RuntimeError("validation_error", `找不到图片文件:${filePath}`, {
recommendation: "确认路径正确,或直接点参数旁的「屏幕框选」截取一张"
});
}
throw error;
}
if (isPngBuffer(buffer)) return PNG.sync.read(buffer);
const { loadImage, createCanvas } = await import("@napi-rs/canvas");
const image = await loadImage(buffer);
if (!image.width || !image.height) {
throw new RuntimeError("validation_error", "图片解码失败", { detail: filePath });
}
const canvas = createCanvas(image.width, image.height);
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
const imageData = context.getImageData(0, 0, image.width, image.height);
const png = new PNG({ width: image.width, height: image.height });
png.data = Buffer.from(imageData.data);
return png;
}
/** 解析区域 JSON:对象 {x,y,width,height}(兼容 left/top、x1y1x2y2 角点)或数组 [x,y,width,height],并夹取到图片边界内 */
export function parseRegion(raw: string, image: PNG): { x: number; y: number; width: number; height: number } {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new RuntimeError("validation_error", "区域 JSON 无法解析", { detail: raw.slice(0, 200) });
}
const numberOf = (value: unknown): number | null => {
const n = Number(value);
return Number.isFinite(n) ? n : null;
};
if (Array.isArray(parsed) && parsed.length === 4) {
const [x, y, width, height] = parsed.map(numberOf);
if (x !== null && y !== null && width !== null && height !== null) {
return clampRegion({ x, y, width, height }, image);
}
}
if (parsed && typeof parsed === "object") {
const record = parsed as Record<string, unknown>;
const x = numberOf(record.x ?? record.left);
const y = numberOf(record.y ?? record.top);
const width = numberOf(record.width);
const height = numberOf(record.height);
if (x !== null && y !== null && width !== null && height !== null) {
return clampRegion({ x, y, width, height }, image);
}
const x2 = numberOf(record.x2 ?? record.right);
const y2 = numberOf(record.y2 ?? record.bottom);
if (x !== null && y !== null && x2 !== null && y2 !== null) {
return clampRegion({ x, y, width: x2 - x, height: y2 - y }, image);
}
}
throw new RuntimeError("validation_error", "区域 JSON 不符合要求", {
detail: raw.slice(0, 200),
recommendation: '使用 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]'
});
}
export function clampRegion(region: { x: number; y: number; width: number; height: number }, image: PNG): { x: number; y: number; width: number; height: number } {
const x = Math.max(0, Math.min(Math.round(region.x), image.width - 1));
const y = Math.max(0, Math.min(Math.round(region.y), image.height - 1));
const width = Math.max(1, Math.min(Math.round(region.width), image.width - x));
const height = Math.max(1, Math.min(Math.round(region.height), image.height - y));
return { x, y, width, height };
}
export function cropPng(source: PNG, region: { x: number; y: number; width: number; height: number }): PNG {
const cropped = new PNG({ width: region.width, height: region.height });
PNG.bitblt(source, cropped, region.x, region.y, region.width, region.height, 0, 0);
return cropped;
}
/** 目标路径容错:填的是文件夹(已存在或以分隔符结尾)时,自动往里存默认文件名 */
async function resolveOutputPath(outputPath: string, defaultName: string): Promise<string> {
if (/[\\/]$/.test(outputPath)) return path.join(outputPath, defaultName);
try {
const stat = await fs.stat(outputPath);
if (stat.isDirectory()) return path.join(outputPath, defaultName);
} catch {
// 路径不存在说明是要新建的文件,原样返回
}
return outputPath;
}
register("Image_Screenshot", async (ctx, node, args) => {
const folder = text(args, ["outImagePath", "folder"]); const folder = text(args, ["outImagePath", "folder"]);
const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`); const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`);
const output = path.resolve(text(args, ["image_path"], folder ? path.join(folder, name) : name)); const imageArg = text(args, ["image_path"]);
const source = text(args, ["pictureSource"], "1");
const range = text(args, ["shotRange"], "1");
let image: PNG;
let output: string;
let screenOrigin: { x: number; y: number } | null = null;
if (source === "2") {
// 指定图片文件来源:image_path 为输入图片;输出走 输出目录+文件名,两者均未配置时直接返回源文件
if (!imageArg.trim()) {
throw new RuntimeError("validation_error", "图像来源为「指定图片文件」时,截图保存路径需填写源图片路径", {
recommendation: "在截图保存路径选择一张已存在的图片,或将图像来源改回「当前屏幕」"
});
}
const sourceFile = path.resolve(imageArg);
image = await decodeImageFile(sourceFile);
output = folder ? path.resolve(path.join(folder, name)) : sourceFile;
} else {
// 当前屏幕来源:image_path 即保存路径(文件夹则自动追加默认文件名),缺省用 输出目录+文件名;再缺省落流程证据目录(路径可预期)
// 截取整个虚拟桌面(含副屏),物理分辨率
const shot = await captureVirtualScreenPng();
image = shot.png;
screenOrigin = { x: shot.originX, y: shot.originY };
output = await resolveOutputPath(
path.resolve(imageArg || (folder ? path.join(folder, name) : path.join(ctx.evidenceDir, name))),
name
);
}
if (range === "2") {
const regionRaw = text(args, ["position", "action", "position_img"]);
if (!regionRaw.trim()) {
throw new RuntimeError("validation_error", "截图范围为「框选区域」时,截图区域需填写区域 JSON", {
recommendation: '在「截图区域」点屏幕框选,或填写 {"x":0,"y":0,"width":100,"height":80},或将截图范围改回「整个屏幕」'
});
}
if (screenOrigin) {
// 屏幕来源: 区域 JSON 是 DIP 坐标(支持副屏),换算物理坐标后减去拼接画布原点再裁剪
const phys = await dipRegionToPhysical(parseRegion(regionRaw, image));
image = cropPng(image, clampRegion({
x: phys.x - screenOrigin.x,
y: phys.y - screenOrigin.y,
width: phys.width,
height: phys.height
}, image));
} else {
// 图片文件来源: 区域按该图片自身像素坐标
image = cropPng(image, parseRegion(regionRaw, image));
}
}
await fs.mkdir(path.dirname(output), { recursive: true }); await fs.mkdir(path.dirname(output), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(output)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`); await fs.writeFile(output, PNG.sync.write(image));
return output; return output;
}); });
interface MatchResult { x: number; y: number; width: number; height: number; score: number; } interface MatchResult { x: number; y: number; width: number; height: number; score: number; }
async function captureScreen(target: string): Promise<string> { interface WindowRect { x: number; y: number; width: number; height: number }
await fs.mkdir(path.dirname(target), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`); /** 按标题(精确→包含)或进程名查找窗口,前置并返回窗口矩形(物理屏幕坐标) */
return target; async function focusWindowRect(windowObj: string): Promise<WindowRect> {
const quoted = psString(windowObj);
const stdout = await powershell(`Add-Type @'
using System; using System.Runtime.InteropServices;
public static class WinRect {
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")] public static extern IntPtr FindWindow(string cls, string title);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
} }
'@;
# 不声明 DPI 感知时 GetWindowRect 返回虚拟化坐标, 与物理分辨率截图对不上
try { [void][WinRect]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {};
$h = [WinRect]::FindWindow($null, ${quoted});
if ($h -eq [IntPtr]::Zero) { $p = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.MainWindowTitle -eq ${quoted} -or $_.MainWindowTitle.Contains(${quoted})) } | Select-Object -First 1; if ($p) { $h = $p.MainWindowHandle } }
if ($h -eq [IntPtr]::Zero) { Write-Output 'NOT_FOUND'; exit }
[WinRect]::ShowWindow($h, 9) | Out-Null;
[WinRect]::SetForegroundWindow($h) | Out-Null;
Start-Sleep -Milliseconds 300;
$r = New-Object WinRect+RECT;
[WinRect]::GetWindowRect($h, [ref]$r) | Out-Null;
Write-Output ("{0},{1},{2},{3}" -f $r.Left, $r.Top, $r.Right, $r.Bottom)`);
const line = stdout.trim().split(/\r?\n/).pop() ?? "";
if (line === "NOT_FOUND") {
throw new RuntimeError("not_found", `未找到窗口「${windowObj}」`, { recommendation: "确认窗口已打开,窗口对象可填完整标题、标题包含关键字或进程名" });
}
const parts = line.split(",").map(Number);
if (parts.length !== 4 || parts.some((v) => !Number.isFinite(v))) {
throw new RuntimeError("desktop_error", "窗口矩形获取失败", { detail: line.slice(0, 120) });
}
const [left, top, right, bottom] = parts;
return { x: left, y: top, width: Math.max(1, right - left), height: Math.max(1, bottom - top) };
}
/** 单通道噪声死区:JPG 压缩伪影/抗锯齿带来 ±10 级内的像素波动,不应计入差异 */
const NOISE_FLOOR = 10;
function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number { function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number {
let difference = 0; let difference = 0;
@@ -34,33 +287,39 @@ function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: n
for (let x = 0; x < template.width; x += sampleStep) { for (let x = 0; x < template.width; x += sampleStep) {
const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4; const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4;
const templateIndex = (y * template.width + x) * 4; const templateIndex = (y * template.width + x) * 4;
difference += Math.abs(screen.data[sourceIndex] - template.data[templateIndex]); for (let c = 0; c < 3; c++) {
difference += Math.abs(screen.data[sourceIndex + 1] - template.data[templateIndex + 1]); const d = Math.abs(screen.data[sourceIndex + c] - template.data[templateIndex + c]);
difference += Math.abs(screen.data[sourceIndex + 2] - template.data[templateIndex + 2]); difference += d <= NOISE_FLOOR ? 0 : d - NOISE_FLOOR;
}
samples += 3; samples += 3;
if (difference > cutoff * samples) return difference / (samples * 255); // cutoff 是 0~1 的归一化差异上限; difference 是通道差累加值(0~255 每通道),
// 提前退出必须按 255 折算——之前漏乘导致几乎所有位置在第一个像素就被剪掉, 得分全乱
if (difference > cutoff * samples * 255) return difference / (samples * 255);
} }
} }
return samples ? difference / (samples * 255) : 1; return samples ? difference / (samples * 255) : 1;
} }
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number): Promise<MatchResult | null> { /** 在屏幕图里扫描模板的最佳位置(粗扫网格 + 精化) */
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]); function scanBest(screen: PNG, template: PNG, maximumDifference: number): MatchResult | null {
const screen = PNG.sync.read(screenBuffer);
const template = PNG.sync.read(templateBuffer);
if (template.width > screen.width || template.height > screen.height) return null;
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18)); const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18));
const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12)); const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));
let best: MatchResult | null = null; let best: MatchResult | null = null;
for (let y = 0; y <= screen.height - template.height; y += scanStep) { for (let y = 0; y <= screen.height - template.height; y += scanStep) {
for (let x = 0; x <= screen.width - template.width; x += scanStep) { for (let x = 0; x <= screen.width - template.width; x += scanStep) {
const difference = pixelDifference(screen, template, x, y, sampleStep, best ? 1 - best.score : maximumDifference); // 尚无 best 时不剪枝(完整评估): 若初始就按 maximumDifference 剪枝,
// 均匀背景位置在少数采样后返回"刚好压线"的部分均值抢占 best,
// 而真位置(常不在网格上)附近的网格点因部分重叠被剪, 精化永远够不到——
// 相似度 0.95 时实测 score=1.0 的真位置被判"未达到阈值"
const cutoff = best ? 1 - best.score : 1;
const difference = pixelDifference(screen, template, x, y, sampleStep, cutoff);
const score = 1 - difference; const score = 1 - difference;
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score }; if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
} }
} }
if (!best || 1 - best.score > maximumDifference) return null; if (!best) return null;
// 注意: 阈值判定必须放在精化之后——粗扫网格(步长=模板短边/12)通常够不到精确位置,
// 粗扫 best 常差在阈值边缘, 精化后才能提到满分; 先判阈值会把真命中误杀
const refine = Math.max(1, scanStep); const refine = Math.max(1, scanStep);
const minX = Math.max(0, best.x - refine); const minX = Math.max(0, best.x - refine);
const maxX = Math.min(screen.width - template.width, best.x + refine); const maxX = Math.min(screen.width - template.width, best.x + refine);
@@ -68,30 +327,230 @@ export async function findImage(templatePath: string, screenshotPath: string, ac
const maxY = Math.min(screen.height - template.height, best.y + refine); const maxY = Math.min(screen.height - template.height, best.y + refine);
for (let y = minY; y <= maxY; y += 1) { for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) { for (let x = minX; x <= maxX; x += 1) {
const difference = pixelDifference(screen, template, x, y, sampleStep, 1 - best.score); // 精化区域只有几百个位置, 完整评估避免同样的剪枝偏置
const difference = pixelDifference(screen, template, x, y, sampleStep, 1);
const score = 1 - difference; const score = 1 - difference;
if (score > best.score) best = { x, y, width: template.width, height: template.height, score }; if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
} }
} }
// 阈值判定: 精化后的最终得分才与 maximumDifference 比较
if (1 - best.score > maximumDifference) return null;
return best; return best;
} }
/** 用 canvas 双线性缩放模板(匹配尺度兜底用) */
async function resizeTemplate(template: PNG, scale: number): Promise<PNG> {
const { createCanvas } = await import("@napi-rs/canvas");
const width = Math.max(1, Math.round(template.width * scale));
const height = Math.max(1, Math.round(template.height * scale));
const source = createCanvas(template.width, template.height);
const sourceContext = source.getContext("2d");
const imageData = sourceContext.createImageData(template.width, template.height);
imageData.data.set(template.data);
sourceContext.putImageData(imageData, 0, 0);
const target = createCanvas(width, height);
const targetContext = target.getContext("2d");
targetContext.imageSmoothingEnabled = true;
targetContext.imageSmoothingQuality = "high";
targetContext.drawImage(source, 0, 0, width, height);
const scaled = targetContext.getImageData(0, 0, width, height);
const png = new PNG({ width, height });
png.data = Buffer.from(scaled.data);
return png;
}
/**
* 模板匹配。extraScales 提供尺度兜底:截图与模板的获取渠道分辨率不一致时
* (如旧版 DIP 截图存下的模板 vs 现在的物理分辨率截图),按给定缩放比重试。
*/
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number, extraScales: number[] = []): Promise<MatchResult | null> {
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]);
const screen = PNG.sync.read(screenBuffer);
// 模板图可能是 JPG 等非 PNG 格式, 按魔数选择解码器
const template = isPngBuffer(templateBuffer) ? PNG.sync.read(templateBuffer) : await decodeImageFile(templatePath);
if (template.width > screen.width || template.height > screen.height) {
throw new RuntimeError("validation_error", `模板图片(${template.width}×${template.height})比截图画面(${screen.width}×${screen.height})还大,无法查找`, {
recommendation: "模板应是屏幕上要寻找的小图案(按钮、图标、文字块),不要用整屏大图"
});
}
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
const best = scanBest(screen, template, maximumDifference);
if (best) return best;
for (const scale of extraScales) {
if (!Number.isFinite(scale) || scale === 1) continue;
const scaled = await resizeTemplate(template, scale);
if (scaled.width > screen.width || scaled.height > screen.height) continue;
const scaledBest = scanBest(screen, scaled, maximumDifference);
if (scaledBest) return scaledBest;
}
return null;
}
async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> { async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> {
const rawTemplate = text(args, ["image", "image_path", "template"]); const rawTemplate = text(args, ["image", "image_path", "template"]);
if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId }); if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId });
const template = path.resolve(rawTemplate); const template = path.resolve(rawTemplate);
// 查找范围:1 整个虚拟桌面(默认,含副屏)/ 2 指定窗口 / 3 指定区域;
// 截图统一为物理分辨率拼接画布, 结果坐标加回画布原点即屏幕物理坐标(可直接用于点击)
const rangeMode = text(args, ["find_range"], "1");
const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`); const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`);
await captureScreen(screenshot); const shot = await captureVirtualScreenPng();
const result = await findImage(template, screenshot, Number(text(args, ["accuracy"], "0.2"))); let offsetX = 0;
if (!result) throw new RuntimeError("not_found", "屏幕中未找到目标图像", { nodeId, recommendation: "重新截取模板,或调低匹配精度" }); let offsetY = 0;
return result; if (rangeMode === "2") {
const windowObj = text(args, ["window_obj"]);
if (!windowObj.trim()) throw new RuntimeError("validation_error", "查找范围为「指定窗口」时,窗口对象不能为空", { nodeId, recommendation: "填写窗口标题(或标题关键字、进程名)" });
const rect = await focusWindowRect(windowObj);
const cropRect = clampRegion({ x: rect.x - shot.originX, y: rect.y - shot.originY, width: rect.width, height: rect.height }, shot.png);
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
offsetX = rect.x;
offsetY = rect.y;
} else if (rangeMode === "3") {
const regionRaw = text(args, ["shot_position", "action", "position"]);
if (!regionRaw.trim()) throw new RuntimeError("validation_error", "查找范围为「指定区域」时,需要填写区域 JSON", { nodeId, recommendation: '在 shot_position 填写 {"x":0,"y":0,"width":100,"height":80} 或 [x, y, width, height]' });
// 区域 JSON 是 DIP 坐标(框选工具按 DIP 返回, 支持副屏负坐标),换算物理坐标后减画布原点裁剪
const phys = await dipRegionToPhysical(parseRegion(regionRaw, shot.png));
const cropRect = clampRegion({ x: phys.x - shot.originX, y: phys.y - shot.originY, width: phys.width, height: phys.height }, shot.png);
await fs.writeFile(screenshot, PNG.sync.write(cropPng(shot.png, cropRect)));
offsetX = phys.x;
offsetY = phys.y;
} else {
await fs.writeFile(screenshot, PNG.sync.write(shot.png));
// 拼接画布坐标 → 屏幕物理坐标必须加回画布原点(副屏在主屏左侧/上方时原点为负);
// 漏加会导致点击偏移(实测副屏上移 177px 时点击正好偏下 177px)
offsetX = shot.originX;
offsetY = shot.originY;
}
// accuracy 字段语义为「相似度」(0~1, 越大越严格, 0.9=至少90%像素一致)——与界面文案一致。
// 引擎内部用最大允许差异 = 1 - 相似度。此前引擎直接把该值当差异阈值,
// 用户填 0.9 反而放宽到"接受90%像素差异", 导致到处误命中。
const rawAccuracy = Number(text(args, ["accuracy"], "0.8"));
const similarity = Math.max(0.05, Math.min(1, Number.isFinite(rawAccuracy) && rawAccuracy > 0 ? rawAccuracy : 0.8));
// 原生 1:1 匹配失败时, 按缩放比对模板放大/缩小重试——兼容旧版 DIP 截图存的模板与任意来源的图片
const scale = (await import("electron")).screen.getPrimaryDisplay().scaleFactor || 1;
const result = await findImage(template, screenshot, 1 - similarity, scale !== 1 ? [scale, 1 / scale] : []);
if (!result) throw new RuntimeError("not_found", `屏幕中未找到目标图像(要求相似度 ${(similarity * 100).toFixed(0)}%,未达到阈值)`, { nodeId, recommendation: "重新框选模板(框小一点、只框目标图案),或适当调低相似度(如 0.8);目标在副屏时确认其清晰可见" });
return { ...result, x: result.x + offsetX, y: result.y + offsetY };
} }
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id)); register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
// 图像位置: 与查找图像同义(历史重复组件), 同管线返回坐标
for (const id of ["Image_Location", "Image_Location_V1"]) register(id, async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
// 判断图像: 找到返回 true / 找不到返回 false(供条件分支), 仅有 not_found 转为 false;
// 模板路径为空/文件不存在等配置错误仍然抛出, 避免静默 false 难排查。
// timeout>0 时在窗口内每 300ms 重试(等待图像出现再判断的场景)
for (const id of ["Image_Exists", "Image_Exists_V1"]) register(id, async (ctx, node, args) => {
const rawTimeout = Number(text(args, ["timeout", "time_out"], "0"));
const deadline = Date.now() + (Number.isFinite(rawTimeout) && rawTimeout > 0 ? rawTimeout : 0) * 1000;
for (;;) {
try {
await locate(args, ctx.evidenceDir, node.id);
return true;
} catch (error) {
if (error instanceof RuntimeError && error.code === "not_found") {
if (Date.now() >= deadline) return false;
} else {
throw error;
}
}
await new Promise(resolve => setTimeout(resolve, 300));
}
});
// 等待图像: 在超时窗口内每 300ms 重试一次查找, 命中即返回坐标, 超时仍无则抛 not_found
for (const id of ["Image_Wait", "Image_Wait_V1"]) register(id, async (ctx, node, args) => {
const rawTimeout = Number(text(args, ["timeout", "time_out"], "10"));
const timeout = Number.isFinite(rawTimeout) && rawTimeout >= 0 ? rawTimeout : 10;
const deadline = Date.now() + timeout * 1000;
for (;;) {
try {
return await locate(args, ctx.evidenceDir, node.id);
} catch (error) {
if (!(error instanceof RuntimeError) || error.code !== "not_found" || Date.now() >= deadline) throw error;
}
await new Promise(resolve => setTimeout(resolve, 300));
}
});
// 移动鼠标到图像: 定位后把光标移到匹配区域中心;
// SetCursorPos 传物理坐标, 脚本必须声明 per-monitor DPI 感知(与 injectClick 同理), 否则被系统虚拟化导致偏移
for (const id of ["Image_Move", "Image_Move_V1", "Image_Move_V2"]) register(id, async (ctx, node, args) => {
const hit = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(hit.x + hit.width / 2);
const y = Math.round(hit.y + hit.height / 2);
await powershell("Add-Type -MemberDefinition '[DllImport(\"user32.dll\")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value); [DllImport(\"user32.dll\")] public static extern bool SetCursorPos(int X, int Y);' -Name RpaImageMoveInput -Namespace RpaImageMove | Out-Null; try { [void][RpaImageMove.RpaImageMoveInput]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {}; [RpaImageMove.RpaImageMoveInput]::SetCursorPos(" + x + "," + y + ")");
return { ...hit, x, y };
});
/** 点击位置锚点(相对匹配区域的比例坐标),支持九宫格取值 */
const POSITION_ANCHORS: Record<string, [number, number]> = {
center: [0.5, 0.5],
leftTop: [0, 0], top: [0.5, 0], centerTop: [0.5, 0], rightTop: [1, 0],
left: [0, 0.5], centerLeft: [0, 0.5], right: [1, 0.5], centerRight: [1, 0.5],
leftBottom: [0, 1], bottom: [0.5, 1], centerBottom: [0.5, 1], rightBottom: [1, 1]
};
export function clickPoint(result: MatchResult, position: string): { x: number; y: number } {
const raw = position.trim();
if (raw && raw.toLowerCase() !== "center") {
if (raw.startsWith("{")) {
try {
const offset = JSON.parse(raw) as { x?: unknown; y?: unknown };
const dx = Number(offset.x);
const dy = Number(offset.y);
if (Number.isFinite(dx) && Number.isFinite(dy)) {
return { x: Math.round(result.x + dx), y: Math.round(result.y + dy) };
}
} catch {
// 非法 JSON 落回中心
}
}
const anchor = POSITION_ANCHORS[raw] ?? POSITION_ANCHORS[raw.toLowerCase()];
if (anchor) {
return { x: Math.round(result.x + result.width * anchor[0]), y: Math.round(result.y + result.height * anchor[1]) };
}
}
return { x: Math.round(result.x + result.width / 2), y: Math.round(result.y + result.height / 2) };
}
const CLICK_ACTIONS: Record<string, string> = {
left: "[Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero)",
right: "[Mouse]::mouse_event(8,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(16,0,0,0,[UIntPtr]::Zero)",
middle: "[Mouse]::mouse_event(32,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 30; [Mouse]::mouse_event(64,0,0,0,[UIntPtr]::Zero)",
double: "[Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 60; [Mouse]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Mouse]::mouse_event(4,0,0,0,[UIntPtr]::Zero)"
};
async function injectClick(x: number, y: number, clickKey: string, showTrack: boolean): Promise<void> {
const action = CLICK_ACTIONS[clickKey] ?? CLICK_ACTIONS.left;
// show_track=1 时从当前光标位置平滑移动过去(20 步插值),否则直接跳转
const move = showTrack
? `for ($i = 1; $i -le 20; $i++) { [Mouse]::SetCursorPos([int]($p.X + (${x} - $p.X) * $i / 20), [int]($p.Y + (${y} - $p.Y) * $i / 20)); Start-Sleep -Milliseconds 12 }`
: `[Mouse]::SetCursorPos(${x}, ${y})`;
await powershell(`Add-Type @'
using System; using System.Runtime.InteropServices;
public static class Mouse {
[DllImport("user32.dll")] public static extern bool SetProcessDpiAwarenessContext(IntPtr value);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT p);
[DllImport("user32.dll")] public static extern void mouse_event(uint f, uint x, uint y, uint d, UIntPtr e);
public struct POINT { public int X; public int Y; }
}
'@;
# 传入的是物理像素坐标(与物理分辨率截图匹配所得), 必须声明 DPI 感知——
# 否则进程被 DPI 虚拟化, 坐标再被系统放大一次, 点击成比例偏右下
try { [void][Mouse]::SetProcessDpiAwarenessContext([IntPtr](-4)) } catch {};
$p = New-Object Mouse+POINT;
[Mouse]::GetCursorPos([ref]$p) | Out-Null;
${move};
Start-Sleep -Milliseconds 60;
${action}`);
}
register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => { register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => {
const result = await locate(args, ctx.evidenceDir, node.id); const result = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(result.x + result.width / 2); const point = clickPoint(result, text(args, ["position"], "center"));
const y = Math.round(result.y + result.height / 2); const clickKey = text(args, ["click_key"], "left");
await powershell(`Add-Type @'\nusing System; using System.Runtime.InteropServices; public static class Clicker { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); }\n'@; [Clicker]::SetCursorPos(${x},${y}); [Clicker]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Clicker]::mouse_event(4,0,0,0,[UIntPtr]::Zero)`); const showTrack = String(args.show_track ?? "0") === "1" || args.show_track === true;
return { ...result, click: { x, y } }; await injectClick(point.x, point.y, clickKey, showTrack);
ctx.log(`已${clickKey === "right" ? "右键" : clickKey === "double" ? "双击" : "点击"}图像 (${point.x}, ${point.y}),匹配得分 ${result.score.toFixed(3)}`, "info", node.id);
return { ...result, click: point };
}); });
+24 -1
View File
@@ -15,7 +15,7 @@ const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0,
async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); } async function run(id: string, args: Record<string, unknown> = {}) { const handler = getHandler(id); expect(handler, id).toBeTruthy(); return handler!(context, node(id), args, component); }
afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })))); afterEach(async () => Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))));
const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close"]; const ids = ["Word_New_V1", "Word_GetPath", "Word_ReadAll", "Word_Replace", "Word_Enter", "Word_InsertPage", "Word_SetFontName", "Word_SetFontSize", "Word_SetFontStyle", "Word_SetFontColor", "Word_SetAlign", "Word_InsertPicture", "Word_WriteTable", "Word_Close", "Word_ReadSelect", "Word_CutSelect", "Word_CopySelect", "Word_PasteSelect", "Word_DeleteSelect", "Word_SelectAll", "Word_SelectRow", "Word_SetPosition", "Word_MovePosition", "Word_PositionToSelect", "Word_ReadTable"];
describe("Word extended capabilities", () => { describe("Word extended capabilities", () => {
it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); }); it("registers automated Word operations", () => { for (const id of ids) expect(getHandler(id), id).toBeTruthy(); });
@@ -33,4 +33,27 @@ describe("Word extended capabilities", () => {
await run("Word_Enter", { objWord: handle }); await run("Word_Enter", { objWord: handle });
expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000); expect((await readFile(path.join(directory, "extended.docx"))).byteLength).toBeGreaterThan(1000);
}, 120000); }, 120000);
it("uses a persistent selection and reads a Word table through COM", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-selection-")); directories.push(directory);
let handle = await run("Word_New_V1", { filePath: directory, name: "selection.docx" });
await run("Word_Write", { objWord: handle, text: "alpha beta gamma" });
handle = await run("Word_PositionToSelect", { objWord: handle, text: "beta", pos_index: 1, direction: "select" });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("beta");
handle = await run("Word_CopySelect", { objWord: handle });
handle = await run("Word_MovePosition", { objWord: handle, length: 1, unit: "word", direction: "right", isSelect: false });
await run("Word_PasteSelect", { objWord: handle });
handle = await run("Word_PositionToSelect", { objWord: handle, text: "gamma", direction: "select" });
handle = await run("Word_CutSelect", { objWord: handle });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toBe("");
await run("Word_DeleteSelect", { objWord: handle });
handle = await run("Word_SetPosition", { objWord: handle, length: -2, unit: "word" });
handle = await run("Word_SelectRow", { objWord: handle, st: 1, ed: 1 });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha");
handle = await run("Word_SelectAll", { objWord: handle });
await expect(run("Word_ReadSelect", { objWord: handle })).resolves.toContain("alpha beta beta");
await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] });
await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]);
}, 180000);
}); });
+140
View File
@@ -122,3 +122,143 @@ register("Word_WriteTable", async (_ctx, node, args) => {
}); });
register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true })); register("Word_Close", (_ctx, node, args) => ({ path: handlePath(args, node.id, node.componentId), closed: true }));
interface WordSelection { start: number; end: number; }
function selectionOf(args: Record<string, unknown>): WordSelection {
const handle = args.objWord;
const raw = handle && typeof handle === "object" ? (handle as { selection?: unknown }).selection : null;
const record = raw && typeof raw === "object" ? raw as Record<string, unknown> : {};
let start = Math.max(0, Math.floor(Number(record.start ?? 0) || 0));
let end = Math.max(0, Math.floor(Number(record.end ?? record.active ?? start) || 0));
if (end < start) [start, end] = [end, start];
return { start, end };
}
function selectionScript(filePath: string, selection: WordSelection, body = "", withText = true): string {
return `$word=$null;$doc=$null;try{` +
`$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});` +
`$sel=$doc.Range(${selection.start},${selection.end});$sel.Select()|Out-Null;${body};if($null -eq $sel){$sel=$doc.Content}` +
`$text="";if(${withText ? "$true" : "$false"}){$text=[string]$sel.Text};$out=@{start=[int]$sel.Start;end=[int]$sel.End;text=$text}|ConvertTo-Json -Compress;` +
`[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($out))|Write-Output;$doc.Save()` +
`}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`;
}
async function runSelection(filePath: string, selection: WordSelection, body = "", withText = true): Promise<WordSelection & { text: string }> {
const output = await powershell(selectionScript(filePath, selection, body, withText));
if (output.includes("__WORD_NOT_FOUND__")) throw new RuntimeError("word_text_not_found", "Word 查找文本不存在", { detail: `path=${filePath}` });
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8")) as { start: number; end: number; text: string };
return { start: parsed.start, end: parsed.end, text: parsed.text };
}
function selectionResult(filePath: string, selection: WordSelection): { path: string; selection: WordSelection } {
return { path: filePath, selection };
}
const selectionText = async (_ctx: unknown, node: { id: string; componentId: string }, args: Record<string, unknown>): Promise<string> =>
(await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args))).text;
const selectionMutation = (body: string): CapabilityHandlerLike =>
async (_ctx, node, args) => selectionResult(handlePath(args, node.id, node.componentId), await runSelection(handlePath(args, node.id, node.componentId), selectionOf(args), body));
type CapabilityHandlerLike = Parameters<typeof register>[1];
register("Word_ReadSelect", selectionText);
register("Word_CopySelect", selectionMutation("$sel.Copy()|Out-Null"));
register("Word_CutSelect", selectionMutation("$sel.Cut()|Out-Null"));
register("Word_DeleteSelect", selectionMutation("[void]$sel.Delete()"));
register("Word_PasteSelect", selectionMutation("$sel.Paste()|Out-Null"));
register("Word_SelectAll", selectionMutation("$sel=$doc.Content;$sel.Select()|Out-Null"));
register("Word_SelectRow", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const startRow = Math.max(1, Math.floor(Number(args.st ?? 1) || 1));
const endRow = Math.max(startRow, Math.floor(Number(args.ed ?? args.start ?? startRow) || startRow));
const body = [
`$first=$doc.GoTo(3,1,${startRow}).Bookmarks("\\Line").Range`,
`$last=$doc.GoTo(3,1,${endRow}).Bookmarks("\\Line").Range`,
"$sel=$doc.Range($first.Start,$last.End);$sel.Select()|Out-Null"
].join(";");
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
});
const wordUnit = (value: unknown): number => {
const unit = String(value ?? "word").toLowerCase();
return unit === "row" || unit === "line" ? 5 : unit === "section" || unit === "paragraph" ? 4 : 2;
};
register("Word_SetPosition", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const count = Math.floor(Number(args.length ?? 1) || 0);
const unit = wordUnit(args.unit);
const body = `$anchor=if(${selectionOf(args).end > selectionOf(args).start ? "$true" : "$false"}){$sel.End}else{$sel.Start};$sel=$doc.Range($anchor,$anchor);[void]$sel.Move(${unit},${count})`;
return selectionResult(filePath, await runSelection(filePath, selectionOf(args), body));
});
register("Word_MovePosition", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const count = Math.floor(Number(args.length ?? 1) || 0);
const unit = wordUnit(args.unit);
const left = String(args.direction ?? "right").toLowerCase().includes("left");
const countValue = left ? -Math.abs(count) : Math.abs(count);
const extend = bool(args.isSelect ?? args.shift ?? false);
const selection = selectionOf(args);
const anchor = left ? selection.start : selection.end;
const body = extend
? `$sel=$doc.Range(${anchor},${anchor});if(${left ? "$true" : "$false"}){[void]$sel.MoveStart(${unit},${countValue})}else{[void]$sel.MoveEnd(${unit},${countValue})}`
: `$sel=$doc.Range(${anchor},${anchor});[void]$sel.Move(${unit},${countValue})`;
return selectionResult(filePath, await runSelection(filePath, selection, body));
});
register("Word_PositionToSelect", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const needle = text(args, ["text", "find", "value"]);
if (!needle) throw new RuntimeError("validation_error", "查找文本为空", { nodeId: node.id, componentId: node.componentId });
const encodedNeedle = Buffer.from(needle, "utf8").toString("base64");
const requested = Math.floor(Number(args.pos_index ?? 1) || 1);
const mode = String(args.direction ?? "left").toLowerCase();
const body = [
`$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedNeedle}'))`,
"$search=$doc.Content;$all=@();while($true){",
" $candidate=$search.Duplicate;$found=$candidate.Find.Execute($needle)",
" if(-not $found){break}",
" $all+=$candidate.Duplicate",
" if($candidate.End -ge $search.End){break}",
" $search.SetRange($candidate.End,$search.End)",
"}",
"if($all.Count -eq 0){Write-Output '__WORD_NOT_FOUND__';exit 0}",
`$ix=${requested};if($ix -le 0){$ix=$all.Count+$ix+1}`,
"if($ix -lt 1){$ix=1};if($ix -gt $all.Count){$ix=$all.Count};$match=$all[$ix-1]",
mode === "select"
? "$sel=$match.Duplicate;$sel.Select()|Out-Null"
: `$p=$match.${mode.includes("right") ? "End" : "Start"};$sel=$doc.Range($p,$p);$sel.Select()|Out-Null`
].join(";");
return selectionResult(filePath, await runSelection(filePath, { start: 0, end: 0 }, body));
});
register("Word_ReadTable", async (_ctx, node, args) => {
const filePath = handlePath(args, node.id, node.componentId);
const byContent = String(args.find_type ?? "index").toLowerCase().includes("content");
const tableIndex = Math.max(1, Math.floor(Number(args.table_index ?? 1) || 1));
const content = String(args.table_content ?? "");
const encodedContent = Buffer.from(content, "utf8").toString("base64");
const selector = byContent
? `$needle=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedContent}'));$tableIndex=0;for($i=1;$i -le $doc.Tables.Count;$i++){if($doc.Tables.Item($i).Range.Text.Contains($needle)){$tableIndex=$i;break}}`
: `$tableIndex=${tableIndex}`;
const body = [
selector,
"if($tableIndex -lt 1 -or $tableIndex -gt $doc.Tables.Count){Write-Output '__WORD_TABLE_NOT_FOUND__';exit 0}",
"$table=$doc.Tables.Item($tableIndex);$rows=@()",
"for($r=1;$r -le $table.Rows.Count;$r++){ $row=@(); for($c=1;$c -le $table.Columns.Count;$c++){ try { $text=$table.Cell($r,$c).Range.Text } catch { $text='' }; $clean=''; foreach($ch in $text.ToCharArray()){ $code=[int]$ch; if($code -ne 13 -and $code -ne 7){$clean += $ch} }; $row += $clean }; $rows += ,$row }",
"$json=$rows|ConvertTo-Json -Compress -Depth 5",
"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($json))|Write-Output"
].join(";");
const output = await powershell(`$word=$null;$doc=$null;try{$word=New-Object -ComObject Word.Application;$word.Visible=$false;$word.DisplayAlerts=0;$doc=$word.Documents.Open(${psString(filePath)});${body}}finally{if($doc -ne $null){try{$doc.Close()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc)}catch{}};if($word -ne $null){try{$word.Quit()}catch{};try{[void][Runtime.InteropServices.Marshal]::ReleaseComObject($word)}catch{}};[GC]::Collect();[GC]::WaitForPendingFinalizers()}`);
if (output.includes("__WORD_TABLE_NOT_FOUND__")) throw new RuntimeError("word_table_not_found", "Word 表格不存在", { detail: `path=${filePath};tableIndex=${tableIndex};content=${content}` });
const parsed = JSON.parse(Buffer.from(output.trim(), "base64").toString("utf8"));
const rows = Array.isArray(parsed) ? parsed : [parsed];
return rows.map((row) => {
const value = row && typeof row === "object" && Array.isArray((row as { value?: unknown }).value) ? (row as { value: unknown[] }).value : row;
return Array.isArray(value) ? value : [value];
});
});
+66 -13
View File
@@ -2,19 +2,10 @@ import { app } from "electron";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { Catalog, CatalogComponent } from "../shared/types"; import type { Catalog, CatalogComponent } from "../shared/types";
import { hasCapability } from "./engine/registry";
let cached: Catalog | null = null; let cached: Catalog | null = null;
let excludedIds: Set<string> | null = null;
const RUNTIME_CONTAINERS = new Set([ let duplicateMap: Map<string, string> | null = null;
"logic_control_if_else",
"logic_control_count_for",
"logic_control_array_for_v3",
"logic_control_dictionary_for_v1",
"logic_control_while",
"infinite_loop",
"logic_control_try_catch"
]);
function resolveCatalogPath(): string { function resolveCatalogPath(): string {
const candidates = [ const candidates = [
@@ -29,6 +20,56 @@ function resolveCatalogPath(): string {
throw new Error(`catalog.json not found; tried: ${candidates.join(", ")}`); throw new Error(`catalog.json not found; tried: ${candidates.join(", ")}`);
} }
function duplicatesPath(): string {
const candidates = [
process.env.RPA_DUPLICATE_PATH,
path.join(process.resourcesPath, "capability-duplicates.json"),
path.join(app.getAppPath(), "resources", "capability-duplicates.json"),
path.join(__dirname, "../../resources/capability-duplicates.json")
].filter(Boolean) as string[];
return candidates.find((candidate) => fs.existsSync(candidate)) || "";
}
function loadDuplicateMap(): Map<string, string> {
const map = new Map<string, string>();
const file = duplicatesPath();
if (!file) return map;
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { decisions?: Array<{ id?: string; status?: string; canonical?: string }> };
for (const decision of parsed.decisions || []) {
if (decision.id && decision.status === "hide" && decision.canonical) map.set(decision.id, decision.canonical);
}
} catch {
// 审计文件损坏时保留完整目录
}
return map;
}
function excludedPath(): string {
const candidates = [
process.env.RPA_EXCLUDED_PATH,
path.join(process.resourcesPath, "excluded.json"),
path.join(app.getAppPath(), "resources", "excluded.json"),
path.join(__dirname, "../../resources/excluded.json")
].filter(Boolean) as string[];
const found = candidates.find((candidate) => fs.existsSync(candidate));
return found || "";
}
function loadExcludedIds(): Set<string> {
if (excludedIds) return excludedIds;
excludedIds = new Set();
const file = excludedPath();
if (!file) return excludedIds;
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as { ids?: string[] };
for (const id of parsed.ids || []) excludedIds.add(id);
} catch {
// 备份文件损坏时不阻塞目录加载
}
return excludedIds;
}
export function catalogPath(): string { export function catalogPath(): string {
return resolveCatalogPath(); return resolveCatalogPath();
} }
@@ -37,10 +78,22 @@ export function loadCatalog(): Catalog {
if (cached) return cached; if (cached) return cached;
const raw = fs.readFileSync(catalogPath(), "utf8"); const raw = fs.readFileSync(catalogPath(), "utf8");
const catalog = JSON.parse(raw) as Catalog; const catalog = JSON.parse(raw) as Catalog;
catalog.components = catalog.components.map((component: CatalogComponent) => ({ const excluded = loadExcludedIds();
const duplicates = duplicateMap || loadDuplicateMap();
duplicateMap = duplicates;
catalog.components = catalog.components
.filter((component) => !excluded.has(component.id))
.map((component: CatalogComponent) => ({
...component, ...component,
implemented: RUNTIME_CONTAINERS.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName) duplicateOf: duplicates.get(component.id)
})); }));
const visibleComponents = catalog.components.filter((component) => !component.duplicateOf);
catalog.counts.total = visibleComponents.length;
catalog.counts.vendor = visibleComponents.filter((component) => component.kind === "vendor").length;
catalog.counts.native = visibleComponents.filter((component) => component.kind === "native").length;
catalog.counts.triggers = visibleComponents.filter((component) => component.kind === "trigger").length;
catalog.counts.fields = visibleComponents.reduce((sum, component) => sum + component.fields.length, 0);
for (const group of catalog.groups) group.count = visibleComponents.filter((component) => component.group === group.id).length;
cached = catalog; cached = catalog;
return catalog; return catalog;
} }
+915 -50
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -8,8 +8,8 @@ function component(id: string): CatalogComponent {
const value = { const value = {
id, name: id, kind: "vendor", group: "data", categories: [], categorySignature: "", description: "", id, name: id, kind: "vendor", group: "data", categories: [], categorySignature: "", description: "",
messageTemplate: "", baseType: id, functionName: id, functionCalls: [], version: "", tags: [], platformTag: null, messageTemplate: "", baseType: id, functionName: id, functionCalls: [], version: "", tags: [], platformTag: null,
icon: "data", risk: "low", fields: [], output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, slots: [], implemented: true icon: "data", risk: "low", fields: [], output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, slots: []
} as CatalogComponent; } as unknown as CatalogComponent;
components.set(id, value); components.set(id, value);
return value; return value;
} }
@@ -83,7 +83,6 @@ function testComponent(id: string): CatalogComponent {
fields: [], fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [], slots: [],
implemented: true
}; };
} }
+1 -1
View File
@@ -265,7 +265,7 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
{ {
nodeId: node.id, nodeId: node.id,
componentId: node.componentId, componentId: node.componentId,
recommendation: "换用已接入组件;该能力已列入后续接入清单" recommendation: "请检查组件配置或重新添加节点"
} }
); );
} }
+19 -1
View File
@@ -1,5 +1,6 @@
import { app } from "electron"; import { app } from "electron";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os";
import path from "node:path"; import path from "node:path";
import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types"; import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types";
@@ -71,12 +72,29 @@ export function createFlow(name = "未命名流程"): FlowDefinition {
}; };
} }
/** 演示流程浏览器类型:优先 Chrome,未安装时回退 Edge;两者都缺失则保持 chrome 由能力层给出友好错误 */
function demoBrowserType(): string {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const chromePaths = [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
];
if (chromePaths.some((p) => fs.existsSync(p))) return "chrome";
const edgePaths = [
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
];
if (edgePaths.some((p) => fs.existsSync(p))) return "edge";
return "chrome";
}
export function createDemoFlow(url: string): FlowDefinition { export function createDemoFlow(url: string): FlowDefinition {
const flow = createFlow("本地网页订单演示"); const flow = createFlow("本地网页订单演示");
flow.description = "打开本地演示站,填写客户和金额,提交订单并读取结果。"; flow.description = "打开本地演示站,填写客户和金额,提交订单并读取结果。";
flow.nodes = [ flow.nodes = [
flow.nodes[0], flow.nodes[0],
{ id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: "chrome", url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } }, { id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: demoBrowserType(), url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } },
{ id: "node_name", componentId: "Element_SetValue_V6", position: { x: 180, y: 390 }, values: { uiSelector: "css:#name", designated_info: "0", designated_obj: "", text: "RPA 客户", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } }, { id: "node_name", componentId: "Element_SetValue_V6", position: { x: 180, y: 390 }, values: { uiSelector: "css:#name", designated_info: "0", designated_obj: "", text: "RPA 客户", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
{ id: "node_amount", componentId: "Element_SetValue_V6", position: { x: 180, y: 530 }, values: { uiSelector: "css:#amount", designated_info: "0", designated_obj: "", text: "128", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } }, { id: "node_amount", componentId: "Element_SetValue_V6", position: { x: 180, y: 530 }, values: { uiSelector: "css:#amount", designated_info: "0", designated_obj: "", text: "128", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
{ id: "node_submit", componentId: "Element_Click_V6", position: { x: 180, y: 670 }, values: { uiSelector: "css:#submit", designated_info: "0", designated_obj: "", clickway: "0", clickfunc: "0", operation: "1" }, options: { timeoutMs: 10000 } }, { id: "node_submit", componentId: "Element_Click_V6", position: { x: 180, y: 670 }, values: { uiSelector: "css:#submit", designated_info: "0", designated_obj: "", clickway: "0", clickfunc: "0", operation: "1" }, options: { timeoutMs: 10000 } },
+36 -5
View File
@@ -1,15 +1,24 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron"; import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import path from "node:path"; import path from "node:path";
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types"; import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult } from "../shared/types";
import { BrowserSession, hasBrowserSession, setBrowserElementWaitMs } from "./browser/session"; import {
BrowserSession,
externalBrowserSession,
hasBrowserSession,
latestExternalSession,
setBrowserElementWaitMs
} from "./browser/session";
import "./capabilities"; import "./capabilities";
import { catalogPath, loadCatalog } from "./catalog"; import { catalogPath, loadCatalog } from "./catalog";
import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows"; import { createDemoFlow, createExampleFlows, createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
import { capabilityCount, capabilityIds } from "./engine/registry"; import { capabilityCount, capabilityIds } from "./engine/registry";
import { closeBrowserSession, startRun, stopRun } from "./run/manager"; import { closeBrowserSession, setMainWindowGetter, startRun, stopRun } from "./run/manager";
import { registerOverlayIpc } from "./overlay";
import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server"; import { fixtureUrl, startFixtureServer, stopFixtureServer } from "./fixture/server";
import { loadSettings, updateSettings } from "./settings"; import { loadSettings, updateSettings } from "./settings";
import { closeLocalVisionWorkers, defaultUiModelPath } from "./local-vision/ui-detect-service";
import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick"; import { pickDesktopElement, listVisibleWindows, pickScreenRegion } from "./desktop-pick";
import { detectVisionLabTarget, listVisionLabSources } from "./vision-lab";
import { logMain } from "./logger"; import { logMain } from "./logger";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@@ -93,6 +102,8 @@ function registerIpc(): void {
ipcMain.handle("catalog:get", () => loadCatalog()); ipcMain.handle("catalog:get", () => loadCatalog());
ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender)); ipcMain.handle("run:start", (event, flow: FlowDefinition) => startRun(flow, event.sender));
setMainWindowGetter(() => mainWindow);
registerOverlayIpc(() => mainWindow, () => { stopRun(); });
ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId)); ipcMain.handle("run:stop", (_event, runId?: string) => stopRun(runId));
ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() })); ipcMain.handle("run:capabilities", () => ({ count: capabilityCount(), ids: capabilityIds() }));
ipcMain.handle("settings:get", () => loadSettings()); ipcMain.handle("settings:get", () => loadSettings());
@@ -101,14 +112,30 @@ function registerIpc(): void {
setBrowserElementWaitMs(settings.browserElementWaitMs); setBrowserElementWaitMs(settings.browserElementWaitMs);
return settings; return settings;
}); });
ipcMain.handle("browser:pick-element", async (_event, options: { url?: string } = {}) => { type BrowserPickOptions = {
url?: string;
browser?: import("./browser/external").ExternalBrowserOptions;
};
ipcMain.handle("browser:pick-element", async (_event, options: BrowserPickOptions = {}) => {
const url = options.url?.trim(); const url = options.url?.trim();
const browserOptions = options.browser;
let session;
if (browserOptions) {
session = await externalBrowserSession(
browserOptions.mode === "launch" && url ? { ...browserOptions, initialUrl: url } : browserOptions
);
} else {
session = latestExternalSession();
}
if (!session) {
if (!hasBrowserSession() && !url) { if (!hasBrowserSession() && !url) {
throw new Error("请先配置或运行「打开网页」,再拾取元素"); throw new Error("请先配置或运行「打开网页」,再拾取元素");
} }
const session = await BrowserSession.create(); session = await BrowserSession.create();
}
const currentUrl = await session.currentUrl(); const currentUrl = await session.currentUrl();
if (url && (currentUrl === "about:blank" || currentUrl !== url)) { if (url && currentUrl !== url) {
await session.navigate(url); await session.navigate(url);
} }
return session.pickElement(); return session.pickElement();
@@ -116,6 +143,9 @@ function registerIpc(): void {
ipcMain.handle("desktop:pick-element", () => pickDesktopElement()); ipcMain.handle("desktop:pick-element", () => pickDesktopElement());
ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows()); ipcMain.handle("desktop:pick-window-list", () => listVisibleWindows());
ipcMain.handle("desktop:pick-region", () => pickScreenRegion()); ipcMain.handle("desktop:pick-region", () => pickScreenRegion());
ipcMain.handle("vision-lab:sources", () => listVisionLabSources());
ipcMain.handle("vision-lab:detect", (_event, request: Parameters<typeof detectVisionLabTarget>[0]) => detectVisionLabTarget(request));
ipcMain.handle("vision-lab:default-model", () => defaultUiModelPath());
ipcMain.handle("flows:list", () => listFlows()); ipcMain.handle("flows:list", () => listFlows());
ipcMain.handle("flows:create", (_event, name?: string) => { ipcMain.handle("flows:create", (_event, name?: string) => {
const flow = createFlow(name); const flow = createFlow(name);
@@ -212,6 +242,7 @@ if (!gotLock) {
app.on("before-quit", () => { app.on("before-quit", () => {
void closeBrowserSession(); void closeBrowserSession();
void stopFixtureServer(); void stopFixtureServer();
closeLocalVisionWorkers();
}); });
} }
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { assignPaneOcr, type PaneOcrLine } from "./pane-ocr-service";
describe("pane OCR assignment", () => {
it("assigns OCR lines to the boxes containing their center", () => {
const lines: PaneOcrLine[] = [
{ text: "登录", confidence: 0.9, x: 12, y: 10, width: 24, height: 12 }
];
const boxes = [
{ candidateId: "button", x: 8, y: 6, width: 48, height: 24 },
{ candidateId: "container", x: 0, y: 0, width: 120, height: 80 }
];
expect(assignPaneOcr(lines, boxes)).toEqual({
button: { ocrText: "登录", ocrState: "done", ocrConfidence: 0.9 },
container: { ocrText: "登录", ocrState: "done", ocrConfidence: 0.9 }
});
});
it("uses a strongly overlapping line even when its center falls outside", () => {
const lines: PaneOcrLine[] = [
{ text: "保存", confidence: 0.8, x: 0, y: 10, width: 30, height: 12 }
];
const boxes = [{ candidateId: "button", x: 8, y: 8, width: 30, height: 16 }];
const result = assignPaneOcr(lines, boxes);
expect(result.button.ocrText).toBe("保存");
expect(result.button.ocrState).toBe("done");
});
it("marks boxes without text as empty", () => {
const result = assignPaneOcr([], [{ candidateId: "image", x: 0, y: 0, width: 20, height: 20 }]);
expect(result.image).toEqual({ ocrText: "", ocrState: "empty" });
});
});
+177
View File
@@ -0,0 +1,177 @@
import { createHash } from "node:crypto";
export interface PaneOcrLine {
text: string;
confidence: number;
x: number;
y: number;
width: number;
height: number;
}
export interface PaneOcrResult {
lines: PaneOcrLine[];
elapsedMs: number;
cacheHit: boolean;
}
export interface CandidateOcrState {
ocrText: string;
ocrState: "pending" | "done" | "empty" | "error";
ocrConfidence?: number;
}
interface GutenLine {
text?: unknown;
mean?: unknown;
score?: unknown;
box?: unknown;
}
interface GutenOcr {
detect: (image: string) => Promise<{ texts?: GutenLine[] }>;
}
type GutenOcrModule = {
default: {
create: (options?: unknown) => Promise<GutenOcr>;
};
};
let ocrPromise: Promise<GutenOcr> | null = null;
const paneCache = new Map<string, Promise<PaneOcrResult>>();
const PANE_CACHE_LIMIT = 32;
async function getOcr(): Promise<GutenOcr> {
if (!ocrPromise) {
ocrPromise = import("@gutenye/ocr-node")
.then(async mod => await (mod as unknown as GutenOcrModule).default.create())
.catch(error => {
ocrPromise = null;
throw error;
});
}
return await ocrPromise;
}
function clamp01(value: unknown): number {
const next = Number(value);
return Number.isFinite(next) ? Math.max(0, Math.min(1, next)) : 0;
}
function lineRect(line: GutenLine): PaneOcrLine {
const points = Array.isArray(line.box) ? line.box : [];
const xs = points
.map(point => Array.isArray(point) ? Number(point[0]) : Number.NaN)
.filter(value => Number.isFinite(value));
const ys = points
.map(point => Array.isArray(point) ? Number(point[1]) : Number.NaN)
.filter(value => Number.isFinite(value));
const x = xs.length ? Math.min(...xs) : 0;
const y = ys.length ? Math.min(...ys) : 0;
const maxX = xs.length ? Math.max(...xs) : x;
const maxY = ys.length ? Math.max(...ys) : y;
return {
text: String(line.text || "").replace(/\s+/g, " ").trim(),
confidence: clamp01(line.mean ?? line.score ?? 0),
x,
y,
width: Math.max(0, maxX - x),
height: Math.max(0, maxY - y)
};
}
/**
* 每张 Pane 截图只做一次整图 OCR。候选框只是画面上的矩形,
* 整图结果按几何关系分配给候选框,天然形成缓存并避免每个框重复推理。
*/
export async function recognizePane(input: {
imagePath: string;
image: Buffer;
}): Promise<PaneOcrResult> {
const imageHash = createHash("sha256").update(input.image).digest("hex");
const cached = paneCache.get(imageHash);
if (cached) {
const result = await cached;
return { ...result, cacheHit: true };
}
const task = (async (): Promise<PaneOcrResult> => {
const started = Date.now();
try {
const ocr = await getOcr();
const response = await ocr.detect(input.imagePath);
const lines = (response.texts || [])
.map(lineRect)
.filter(line => line.text.length > 0);
return { lines, elapsedMs: Date.now() - started, cacheHit: false };
} catch (error) {
// 初始化或识别失败时清掉缓存任务,下一次悬浮仍有机会恢复。
paneCache.delete(imageHash);
throw error;
}
})();
paneCache.set(imageHash, task);
while (paneCache.size > PANE_CACHE_LIMIT) {
const oldest = paneCache.keys().next().value;
if (!oldest) break;
paneCache.delete(oldest);
}
return await task;
}
export function clearPaneOcrCache(): void {
paneCache.clear();
}
interface AssignableBox {
candidateId: string;
x: number;
y: number;
width: number;
height: number;
}
function centerInsideBox(line: PaneOcrLine, box: AssignableBox): boolean {
const cx = line.x + line.width / 2;
const cy = line.y + line.height / 2;
return cx >= box.x && cx <= box.x + box.width && cy >= box.y && cy <= box.y + box.height;
}
function overlapRatio(line: PaneOcrLine, box: AssignableBox): number {
const overlapX = Math.max(0, Math.min(line.x + line.width, box.x + box.width) - Math.max(line.x, box.x));
const overlapY = Math.max(0, Math.min(line.y + line.height, box.y + box.height) - Math.max(line.y, box.y));
const lineArea = Math.max(1, line.width * line.height);
return (overlapX * overlapY) / lineArea;
}
export function assignPaneOcr(
lines: PaneOcrLine[],
boxes: AssignableBox[]
): Record<string, CandidateOcrState> {
const result: Record<string, CandidateOcrState> = {};
for (const box of boxes) {
const matched = lines
.map(line => ({
line,
inside: centerInsideBox(line, box),
ratio: overlapRatio(line, box)
}))
.filter(item => item.inside || item.ratio >= 0.55)
.sort((a, b) => Number(b.inside) - Number(a.inside) || b.ratio - a.ratio || a.line.y - b.line.y || a.line.x - b.line.x);
if (!matched.length) {
result[box.candidateId] = { ocrText: "", ocrState: "empty" };
continue;
}
const text = matched.map(item => item.line.text).join(" ").replace(/\s+/g, " ").trim();
const confidence = matched.reduce((sum, item) => sum + item.line.confidence, 0) / matched.length;
result[box.candidateId] = {
ocrText: text,
ocrState: text ? "done" : "empty",
ocrConfidence: Math.round(confidence * 1000) / 1000
};
}
return result;
}
+153
View File
@@ -0,0 +1,153 @@
import fs from "node:fs";
import path from "node:path";
import { Worker } from "node:worker_threads";
import { app } from "electron";
import { RuntimeError } from "../engine/types";
import type { LocalUiBox } from "./ui-detect-worker";
export interface UiDetectOptions {
image: Buffer;
width: number;
height: number;
modelPath?: string;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectRequest {
id: number;
image: Buffer;
width: number;
height: number;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectResponse {
id: number;
boxes?: LocalUiBox[];
error?: string;
elapsedMs?: number;
}
const workers = new Map<string, Worker>();
const pending = new Map<number, { resolve: (boxes: LocalUiBox[]) => void; reject: (error: unknown) => void; worker: Worker }>();
let nextRequestId = 1;
const MAX_LOADED_WORKERS = 4;
export function localUiModelPath(): string {
const rootCandidates = [
process.resourcesPath,
app.getAppPath() ? path.join(app.getAppPath(), "resources") : "",
path.join(app.getAppPath(), "temp", "models")
].filter(Boolean);
// webpage-element 是实测后的首选模型;保留旧模型作为老安装包的兼容回退。
const fileCandidates = rootCandidates.flatMap(root => [
path.join(root, "models", "ui-element-detect", "webpage-element.onnx"),
path.join(root, "webpage-element.onnx"),
path.join(root, "models", "ui-icon-detect", "model.onnx")
]);
const found = fileCandidates.find(candidate => {
try { return fs.existsSync(candidate) && fs.statSync(candidate).isFile(); }
catch { return false; }
});
if (!found) {
throw new RuntimeError("local_vision_model_missing", "本地 UI 检测模型缺失", {
recommendation: "重新安装应用,或补齐 resources/models/ui-element-detect/webpage-element.onnx"
});
}
return found;
}
export function defaultUiModelPath(): string {
return localUiModelPath();
}
function resolveModelPath(candidate?: string): string {
if (!candidate) return localUiModelPath();
const normalized = path.resolve(candidate);
if (!fs.existsSync(normalized) || !fs.statSync(normalized).isFile()) {
throw new RuntimeError("local_vision_model_missing", "模型文件不存在:" + normalized);
}
if (path.extname(normalized).toLowerCase() !== ".onnx") {
throw new RuntimeError("local_vision_model_invalid", "请选择 ONNX 模型文件");
}
return normalized;
}
function createWorker(modelPath: string): Worker {
const next = new Worker(path.join(__dirname, "local-vision/ui-detect-worker.js"), {
workerData: { modelPath },
resourceLimits: { maxOldGenerationSizeMb: 512 }
});
next.on("message", (message: DetectResponse) => {
const entry = pending.get(message.id);
if (!entry || entry.worker !== next) return;
pending.delete(message.id);
if (message.error) {
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测失败:" + message.error));
return;
}
entry.resolve(message.boxes || []);
});
next.on("error", error => {
for (const [id, entry] of pending) {
if (entry.worker === next) {
entry.reject(error);
pending.delete(id);
}
}
workers.delete(modelPath);
});
next.on("exit", code => {
for (const [id, entry] of pending) {
if (entry.worker === next) {
entry.reject(new RuntimeError("local_vision_failed", "本地 UI 检测进程已退出:" + code));
pending.delete(id);
}
}
if (workers.get(modelPath) === next) workers.delete(modelPath);
});
return next;
}
function getWorker(modelPath: string): Worker {
const existing = workers.get(modelPath);
if (existing) return existing;
while (workers.size >= MAX_LOADED_WORKERS) {
const oldest = workers.keys().next().value;
if (!oldest) break;
const worker = workers.get(oldest);
workers.delete(oldest);
void worker?.terminate();
}
const created = createWorker(modelPath);
workers.set(modelPath, created);
return created;
}
export async function detectUiElements(input: UiDetectOptions): Promise<LocalUiBox[]> {
const modelPath = resolveModelPath(input.modelPath);
const current = getWorker(modelPath);
const nextId = nextRequestId++;
const request: DetectRequest = {
id: nextId,
image: input.image,
width: input.width,
height: input.height,
scoreThreshold: input.scoreThreshold,
iouThreshold: input.iouThreshold
};
return new Promise<LocalUiBox[]>((resolve, reject) => {
pending.set(nextId, { resolve, reject, worker: current });
current.postMessage(request);
});
}
export function closeLocalVisionWorkers(): void {
for (const [modelPath, worker] of workers) {
workers.delete(modelPath);
void worker.terminate();
}
pending.clear();
}
+229
View File
@@ -0,0 +1,229 @@
import fs from "node:fs";
import { parentPort, workerData } from "node:worker_threads";
import { InferenceSession, Tensor } from "onnxruntime-node";
import { createCanvas, loadImage } from "@napi-rs/canvas";
export interface LocalUiBox {
x: number;
y: number;
width: number;
height: number;
category: string;
confidence: number;
}
interface DetectRequest {
id: number;
image: Buffer;
width: number;
height: number;
scoreThreshold?: number;
iouThreshold?: number;
}
interface DetectResponse {
id: number;
boxes?: LocalUiBox[];
error?: string;
elapsedMs?: number;
}
type ModelOutput = { output0: Tensor };
type TensorLike = { dims: number[]; data: Float32Array };
const inputName = "images";
const outputName = "output0";
const inputSize = 640;
const defaultScoreThreshold = 0.25;
const defaultIouThreshold = 0.42;
const maxBoxes = 160;
const startupModelPath = String((workerData as { modelPath?: string }).modelPath || "");
const modelLabels = readModelLabels(startupModelPath);
let sessionPromise: Promise<InferenceSession> | null = null;
function loadSession(): Promise<InferenceSession> {
if (!sessionPromise) {
const modelPath = String((workerData as { modelPath?: string }).modelPath || "");
sessionPromise = InferenceSession.create(modelPath, {
executionProviders: ["cpu"],
graphOptimizationLevel: "all",
executionMode: "sequential"
});
}
return sessionPromise;
}
function iou(a: LocalUiBox, b: LocalUiBox): number {
const x1 = Math.max(a.x, b.x);
const y1 = Math.max(a.y, b.y);
const x2 = Math.min(a.x + a.width, b.x + b.width);
const y2 = Math.min(a.y + a.height, b.y + b.height);
const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
if (intersection <= 0) return 0;
return intersection / (a.width * a.height + b.width * b.height - intersection);
}
function nms(boxes: LocalUiBox[], iouThreshold: number): LocalUiBox[] {
const sorted = [...boxes].sort((a, b) => b.confidence - a.confidence);
const kept: LocalUiBox[] = [];
for (const box of sorted) {
if (kept.every(item => iou(item, box) <= iouThreshold)) kept.push(box);
}
return kept;
}
function tensorAccessor(output: TensorLike, anchorCount: number, channelCount: number, channelFirst: boolean) {
return (channel: number, anchor: number): number => channelFirst
? output.data[channel * anchorCount + anchor]
: output.data[anchor * channelCount + channel];
}
// YOLO 导出的 ONNX 通常不含标签元数据。约定同名 *.labels.json 保存类别:
// 1) ["button", "input"] 2) { "labels": ["button", "input"] }
function readModelLabels(modelPath: string): string[] | undefined {
const sidecarPath = modelPath.replace(/\.onnx$/i, ".labels.json");
try {
const parsed: unknown = JSON.parse(fs.readFileSync(sidecarPath, "utf8"));
const values = Array.isArray(parsed) ? parsed : (parsed as { labels?: unknown })?.labels;
if (!Array.isArray(values)) return undefined;
const labels = values
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(item => String(item))
.filter(Boolean);
return labels.length ? labels : undefined;
} catch {
return undefined;
}
}
function extractBoxes(
output: TensorLike,
sourceWidth: number,
sourceHeight: number,
scoreThreshold: number,
iouThreshold: number
): LocalUiBox[] {
// YOLO/OmniParser exports are commonly [1,C,N] or [1,N,C]. C=4+classCount.
if (output.dims.length < 2) throw new Error(`不支持的模型输出维度:${output.dims.join("x")}`);
const [, first, second] = output.dims;
const channelFirst = first <= second;
const channelCount = channelFirst ? first : second;
const anchorCount = channelFirst ? second : first;
if (channelCount < 5) throw new Error(`模型输出通道数异常:${channelCount}`);
const at = tensorAccessor(output, anchorCount, channelCount, channelFirst);
const classCount = channelCount - 4;
const padded = Math.max(sourceWidth, sourceHeight);
const scale = inputSize / padded;
const drawnWidth = Math.round(sourceWidth * scale);
const drawnHeight = Math.round(sourceHeight * scale);
const offsetX = (inputSize - drawnWidth) / 2;
const offsetY = (inputSize - drawnHeight) / 2;
const minSide = Math.max(8, Math.min(sourceWidth, sourceHeight) * 0.006);
const labels = modelLabels;
const raw: LocalUiBox[] = [];
for (let index = 0; index < anchorCount; index++) {
let confidence = 0;
let classIndex = 0;
if (classCount === 1) {
confidence = at(4, index);
} else {
for (let candidate = 0; candidate < classCount; candidate++) {
const score = at(4 + candidate, index);
if (score > confidence) {
confidence = score;
classIndex = candidate;
}
}
}
if (!Number.isFinite(confidence) || confidence < scoreThreshold) continue;
const cx = at(0, index);
const cy = at(1, index);
const width = at(2, index);
const height = at(3, index);
let x = (cx - width / 2 - offsetX) / scale;
let y = (cy - height / 2 - offsetY) / scale;
let boxWidth = width / scale;
let boxHeight = height / scale;
x = Math.max(0, Math.min(sourceWidth - 1, x));
y = Math.max(0, Math.min(sourceHeight - 1, y));
boxWidth = Math.max(2, Math.min(sourceWidth - x, boxWidth));
boxHeight = Math.max(2, Math.min(sourceHeight - y, boxHeight));
if (boxWidth < minSide || boxHeight < minSide) continue;
const areaRatio = (boxWidth * boxHeight) / (sourceWidth * sourceHeight);
if (areaRatio > 0.92) continue;
raw.push({
x: Math.round(x),
y: Math.round(y),
width: Math.round(boxWidth),
height: Math.round(boxHeight),
category: labels?.[classIndex]
?? (classCount <= 1 ? "控件" : `类别${classIndex + 1}`),
confidence: Math.round(Math.max(0, Math.min(1, confidence)) * 1000) / 1000
});
}
return nms(raw, iouThreshold)
.sort((a, b) => b.confidence - a.confidence || a.y - b.y || a.x - b.x)
.slice(0, maxBoxes)
.sort((a, b) => a.y - b.y || a.x - b.x);
}
async function detect(request: DetectRequest): Promise<LocalUiBox[]> {
const model = await loadSession();
const source = await loadImage(request.image);
const width = request.width || source.width;
const height = request.height || source.height;
const canvas = createCanvas(inputSize, inputSize);
const context = canvas.getContext("2d");
context.fillStyle = "#727272";
context.fillRect(0, 0, inputSize, inputSize);
context.imageSmoothingEnabled = true;
context.imageSmoothingQuality = "high";
const scale = Math.min(inputSize / width, inputSize / height);
context.drawImage(
source,
(inputSize - width * scale) / 2,
(inputSize - height * scale) / 2,
width * scale,
height * scale
);
const pixels = context.getImageData(0, 0, inputSize, inputSize).data;
const tensorData = new Float32Array(3 * inputSize * inputSize);
const planeSize = inputSize * inputSize;
for (let index = 0; index < planeSize; index++) {
tensorData[index] = pixels[index * 4] / 255;
tensorData[planeSize + index] = pixels[index * 4 + 1] / 255;
tensorData[planeSize * 2 + index] = pixels[index * 4 + 2] / 255;
}
const results = await model.run({ [inputName]: new Tensor("float32", tensorData, [1, 3, inputSize, inputSize]) });
const output = results[outputName] as unknown as TensorLike;
return extractBoxes(
output,
width,
height,
request.scoreThreshold ?? defaultScoreThreshold,
request.iouThreshold ?? defaultIouThreshold
);
}
parentPort?.on("message", async (request: DetectRequest) => {
const started = Date.now();
try {
const boxes = await detect(request);
const response: DetectResponse = { id: request.id, boxes, elapsedMs: Date.now() - started };
parentPort?.postMessage(response);
} catch (error) {
const response: DetectResponse = {
id: request.id,
error: error instanceof Error ? error.message : String(error),
elapsedMs: Date.now() - started
};
parentPort?.postMessage(response);
}
});
+155
View File
@@ -0,0 +1,155 @@
import { BrowserWindow, ipcMain, screen } from "electron";
import fs from "node:fs";
import path from "node:path";
import { app } from "electron";
let overlayWindow: BrowserWindow | null = null;
function writeOverlayHtml(): string {
const dir = path.join(app.getPath("temp"), "rpa-studio-overlay");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, "overlay.html");
const html = `<!DOCTYPE html>
<html><head><style>
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-user-select:none}
body{width:320px;height:56px;background:#1E2530;color:#E8ECF2;font-family:'Segoe UI',sans-serif;display:flex;align-items:center;padding:0 12px;gap:10px;overflow:hidden;border-radius:8px;border:1px solid #3A4556}
.spinner{width:14px;height:14px;border:2px solid #4A90D9;border-top-color:transparent;border-radius:50%;animation:spin .8s linear infinite;flex-shrink:0}
@keyframes spin{to{transform:rotate(360deg)}}
.info{flex:1;min-width:0}
.step{font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#B8C4D4}
.progress-bar{height:3px;background:#3A4556;border-radius:2px;margin-top:4px;overflow:hidden}
.progress-fill{height:100%;background:#4A90D9;border-radius:2px;transition:width .3s ease}
.meta{font-size:10px;color:#7A8898;flex-shrink:0;white-space:nowrap}
.stop-btn{background:#3A2028;color:#E05252;border:1px solid #5A3038;border-radius:4px;padding:4px 8px;font-size:10px;cursor:pointer;flex-shrink:0}
.stop-btn:hover{background:#4A2830}
</style></head><body>
<div class="spinner" id="spin"></div>
<div class="info">
<div class="step" id="step">准备运行…</div>
<div class="progress-bar"><div class="progress-fill" id="fill" style="width:0%"></div></div>
</div>
<div class="meta" id="meta">0/0</div>
<button class="stop-btn" id="stopBtn" onclick="stopRun()">停止</button>
<script>
const { ipcRenderer } = require("electron");
let done = 0, total = 0, startTime = Date.now();
let timer = setInterval(function() {
var s = Math.floor((Date.now() - startTime) / 1000);
document.getElementById("meta").textContent = done + "/" + total + " · " + s + "s";
}, 1000);
ipcRenderer.on("run:event", function(_e, ev) {
if (ev.type === "node.started" && ev.nodeLabel) {
document.getElementById("step").textContent = ev.nodeLabel;
}
if (ev.type === "run.started") { startTime = Date.now(); }
if (ev.type === "run.total" && ev.total) { total = ev.total; }
if (ev.type === "node.finished") { done++; updateBar(); }
if (ev.type === "run.finished") {
clearInterval(timer);
document.getElementById("spin").style.display = "none";
var fill = document.getElementById("fill");
fill.style.background = ev.status === "succeeded" ? "#4CAF50" : "#E05252";
fill.style.width = "100%";
document.getElementById("step").textContent =
ev.status === "succeeded" ? "✓ 运行成功" :
ev.status === "failed" ? "✗ 运行失败" : "已取消";
document.getElementById("stopBtn").textContent = "关闭";
document.getElementById("stopBtn").onclick = function() { ipcRenderer.send("overlay:close"); };
setTimeout(function() { ipcRenderer.send("overlay:close"); }, ev.status === "succeeded" ? 3000 : 10000);
}
});
function updateBar() {
var pct = total > 0 ? Math.round(done / total * 100) : 0;
document.getElementById("fill").style.width = pct + "%";
document.getElementById("meta").textContent = done + "/" + total;
}
function stopRun() { ipcRenderer.send("overlay:stop"); }
</script>
</body></html>`;
fs.writeFileSync(file, html, "utf8");
return file;
}
export function showRunOverlay(mainWindow: BrowserWindow | null): void {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.minimize();
mainWindow.setProgressBar(0);
}
if (overlayWindow && !overlayWindow.isDestroyed()) {
overlayWindow.show();
return;
}
const w = 320;
const h = 56;
const workArea = screen.getPrimaryDisplay().workArea;
const x = workArea.x + workArea.width - w - 16;
const y = workArea.y + workArea.height - h - 16;
overlayWindow = new BrowserWindow({
width: w,
height: h,
x,
y,
frame: false,
resizable: false,
movable: false,
minimizable: false,
maximizable: false,
closable: false,
skipTaskbar: true,
alwaysOnTop: true,
fullscreenable: false,
hasShadow: true,
backgroundColor: "#1E2530",
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
sandbox: false
}
});
overlayWindow.setAlwaysOnTop(true, "screen-saver");
overlayWindow.show();
const file = writeOverlayHtml();
overlayWindow.loadFile(file);
}
export function updateOverlayProgress(ratio: number): void {
if (overlayWindow && !overlayWindow.isDestroyed()) {
overlayWindow.setProgressBar(ratio >= 1 ? -1 : Math.max(0, Math.min(1, ratio)));
}
}
export function hideRunOverlay(mainWindow: BrowserWindow | null): void {
if (overlayWindow && !overlayWindow.isDestroyed()) {
overlayWindow.destroy();
overlayWindow = null;
}
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setProgressBar(-1);
mainWindow.restore();
}
}
export function registerOverlayIpc(
getMainWindow: () => BrowserWindow | null,
onStop: () => void
): void {
ipcMain.on("overlay:close", () => {
hideRunOverlay(getMainWindow());
});
ipcMain.on("overlay:stop", () => {
onStop();
});
}
export function overlayBroadcast(event: unknown): void {
if (overlayWindow && !overlayWindow.isDestroyed()) {
overlayWindow.webContents.send("run:event", event);
}
}
+16 -1
View File
@@ -1,4 +1,5 @@
import { app, type WebContents } from "electron"; import { app, type WebContents } from "electron";
import { overlayBroadcast, hideRunOverlay, showRunOverlay } from "../overlay";
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import type { FlowDefinition, RunEvent } from "../../shared/types"; import type { FlowDefinition, RunEvent } from "../../shared/types";
@@ -8,6 +9,9 @@ import { BrowserSession } from "../browser/session";
const active = new Map<string, AbortController>(); const active = new Map<string, AbortController>();
let currentRunId: string | null = null; let currentRunId: string | null = null;
let _getMainWindow: () => import("electron").BrowserWindow | null = () => null;
export function setMainWindowGetter(fn: () => import("electron").BrowserWindow | null) { _getMainWindow = fn; }
function getMainWindow() { return _getMainWindow(); }
function newRunId(): string { function newRunId(): string {
return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`; return `run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
@@ -24,12 +28,22 @@ export function startRun(flow: FlowDefinition, sender: WebContents): { runId: st
const controller = new AbortController(); const controller = new AbortController();
active.set(runId, controller); active.set(runId, controller);
currentRunId = runId; currentRunId = runId;
showRunOverlay(getMainWindow());
const catalog = loadCatalog(); const catalog = loadCatalog();
const components = new Map(catalog.components.map((item) => [item.id, item])); const components = new Map(catalog.components.map((item) => [item.id, item]));
const nodeLabels = new Map<string, string>();
for (const node of flow.nodes) {
nodeLabels.set(node.id, node.label || node.componentId);
}
const emit = (event: Omit<RunEvent, "runId">) => { const emit = (event: Omit<RunEvent, "runId">) => {
if (!sender.isDestroyed()) sender.send("run:event", { ...event, runId }); const enriched = { ...event, runId } as RunEvent;
if (event.type === "node.started" && event.nodeId) {
enriched.nodeLabel = nodeLabels.get(event.nodeId);
}
if (!sender.isDestroyed()) sender.send("run:event", enriched);
overlayBroadcast(enriched);
}; };
const log: (message: string, level?: "info" | "warn" | "error", nodeId?: string) => void = ( const log: (message: string, level?: "info" | "warn" | "error", nodeId?: string) => void = (
message, message,
@@ -59,6 +73,7 @@ export function startRun(flow: FlowDefinition, sender: WebContents): { runId: st
} finally { } finally {
active.delete(runId); active.delete(runId);
if (currentRunId === runId) currentRunId = null; if (currentRunId === runId) currentRunId = null;
hideRunOverlay(getMainWindow());
} }
})(); })();
+4 -4
View File
@@ -22,14 +22,14 @@ describe("app settings", () => {
}); });
it("returns the default element wait when no settings file exists", () => { it("returns the default element wait when no settings file exists", () => {
expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "" } }); expect(loadSettings()).toEqual({ browserElementWaitMs: 5000, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
}); });
it("persists and clamps a user-adjustable element wait", async () => { it("persists and clamps a user-adjustable element wait", async () => {
const saved = updateSettings({ browserElementWaitMs: 12500 }); const saved = updateSettings({ browserElementWaitMs: 12500 });
expect(saved).toEqual({ browserElementWaitMs: 12500, aiConfig: { baseUrl: "", apiKey: "", model: "" } }); expect(saved).toEqual({ browserElementWaitMs: 12500, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved); expect(JSON.parse(await readFile(path.join(electronState.userData, "settings.json"), "utf8"))).toEqual(saved);
expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } }); expect(updateSettings({ browserElementWaitMs: -5 })).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "" } }); expect(loadSettings()).toEqual({ browserElementWaitMs: 0, aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" } });
}); });
}); });
+3 -2
View File
@@ -5,7 +5,7 @@ import type { AppSettings } from "../shared/types";
export const DEFAULT_APP_SETTINGS: AppSettings = { export const DEFAULT_APP_SETTINGS: AppSettings = {
browserElementWaitMs: 5000, browserElementWaitMs: 5000,
aiConfig: { baseUrl: "", apiKey: "", model: "" } aiConfig: { baseUrl: "", apiKey: "", model: "", visionModel: "" }
}; };
let cache: AppSettings | null = null; let cache: AppSettings | null = null;
@@ -25,7 +25,8 @@ function normalizeSettings(value: unknown): AppSettings {
aiConfig: { aiConfig: {
baseUrl: String(ai.baseUrl ?? ""), baseUrl: String(ai.baseUrl ?? ""),
apiKey: String(ai.apiKey ?? ""), apiKey: String(ai.apiKey ?? ""),
model: String(ai.model ?? "") model: String(ai.model ?? ""),
visionModel: String(ai.visionModel ?? "")
} }
}; };
} }
+70
View File
@@ -0,0 +1,70 @@
import { desktopCapturer } from "electron";
import path from "node:path";
import type { VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types";
import { RuntimeError } from "./engine/types";
import { defaultUiModelPath, detectUiElements } from "./local-vision/ui-detect-service";
function sourceKind(sourceId: string): "window" | "screen" {
return sourceId.startsWith("screen:") ? "screen" : "window";
}
function clamp(value: unknown, fallback: number, min: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.min(max, Math.max(min, parsed));
}
export async function listVisionLabSources(): Promise<VisionLabSource[]> {
const sources = await desktopCapturer.getSources({
types: ["window", "screen"],
thumbnailSize: { width: 360, height: 210 },
fetchWindowIcons: true
});
return sources
.filter(source => source.thumbnail.isEmpty() === false)
.filter(source => sourceKind(source.id) === "screen" || source.name !== "RPA Studio")
.map(source => ({
id: source.id,
kind: sourceKind(source.id),
name: source.name || (sourceKind(source.id) === "screen" ? "屏幕" : "未命名窗口"),
thumbnail: source.thumbnail.toDataURL(),
icon: source.appIcon?.toDataURL()
}));
}
export async function detectVisionLabTarget(request: VisionLabDetectRequest): Promise<VisionLabDetection> {
if (!request?.sourceId) throw new RuntimeError("vision_lab_source_required", "请先选择识别目标");
const sourceType = sourceKind(request.sourceId);
const captureStarted = Date.now();
const sources = await desktopCapturer.getSources({
types: [sourceType],
thumbnailSize: { width: 1280, height: 800 }
});
const source = sources.find(item => item.id === request.sourceId);
if (!source) throw new RuntimeError("vision_lab_source_missing", "识别目标已失效,请重新选择窗口");
if (source.thumbnail.isEmpty()) throw new RuntimeError("vision_lab_capture_failed", "目标窗口没有可捕获的画面");
const image = source.thumbnail.toPNG();
const width = source.thumbnail.getSize().width;
const height = source.thumbnail.getSize().height;
const detectStarted = Date.now();
const boxes = await detectUiElements({
image,
width,
height,
modelPath: request.modelPath,
scoreThreshold: clamp(request.scoreThreshold, 0.25, 0.01, 0.95),
iouThreshold: clamp(request.iouThreshold, 0.42, 0.05, 0.9)
});
return {
image: source.thumbnail.toDataURL(),
width,
height,
boxes,
captureMs: detectStarted - captureStarted,
detectMs: Date.now() - detectStarted,
modelPath: path.resolve(request.modelPath || defaultUiModelPath()),
sourceName: source.name || (sourceType === "screen" ? "屏幕" : "未命名窗口")
};
}
+8 -3
View File
@@ -1,5 +1,5 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent } from "../shared/types"; import type { AppInfo, AppSettings, Catalog, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, RunEvent, VisionLabDetectRequest, VisionLabDetection, VisionLabSource } from "../shared/types";
const api = { const api = {
app: { app: {
@@ -9,7 +9,7 @@ const api = {
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get") get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
}, },
desktop: { desktop: {
pickElement: () => ipcRenderer.invoke('desktop:pick-element'), pickElement: () => ipcRenderer.invoke("desktop:pick-element"),
pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'), pickWindowList: () => ipcRenderer.invoke('desktop:pick-window-list'),
pickRegion: () => ipcRenderer.invoke('desktop:pick-region') pickRegion: () => ipcRenderer.invoke('desktop:pick-region')
}, },
@@ -29,9 +29,14 @@ const api = {
ipcRenderer.invoke("dialog:save", options || {}) ipcRenderer.invoke("dialog:save", options || {})
}, },
browser: { browser: {
pickElement: (options?: { url?: string }): Promise<import("../shared/types").ElementPickResult> => pickElement: (options?: { url?: string; browser?: { mode: "launch"; browserType: "chrome" | "edge"; port?: number; extraArgs?: string[] } | { mode: "remote"; host: string; port: number } }): Promise<import("../shared/types").ElementPickResult> =>
ipcRenderer.invoke("browser:pick-element", options || {}) ipcRenderer.invoke("browser:pick-element", options || {})
}, },
visionLab: {
sources: (): Promise<VisionLabSource[]> => ipcRenderer.invoke("vision-lab:sources"),
detect: (request: VisionLabDetectRequest): Promise<VisionLabDetection> => ipcRenderer.invoke("vision-lab:detect", request),
defaultModel: (): Promise<string> => ipcRenderer.invoke("vision-lab:default-model")
},
settings: { settings: {
get: (): Promise<AppSettings> => ipcRenderer.invoke("settings:get"), get: (): Promise<AppSettings> => ipcRenderer.invoke("settings:get"),
update: (patch: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke("settings:update", patch || {}) update: (patch: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke("settings:update", patch || {})
+15 -5
View File
@@ -1,16 +1,17 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Bot, CircleAlert, Loader2, ListChecks } from "lucide-react"; import { Bot, CircleAlert, Loader2, ListChecks, Microscope } from "lucide-react";
import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types"; import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types";
import { useEditorStore } from "./store/editorStore"; import { useEditorStore } from "./store/editorStore";
import { useRunStore } from "./store/runStore"; import { useRunStore } from "./store/runStore";
import { EditorPage } from "./pages/EditorPage"; import { EditorPage } from "./pages/EditorPage";
import { RunsPage } from "./pages/RunsPage"; import { RunsPage } from "./pages/RunsPage";
import { SettingsPage } from "./pages/SettingsPage"; import { SettingsPage } from "./pages/SettingsPage";
import { VisionLabPage } from "./pages/VisionLabPage";
import { TasksPage } from "./pages/TasksPage"; import { TasksPage } from "./pages/TasksPage";
import { railIcons } from "./components/icons"; import { railIcons } from "./components/icons";
import { validateFlow } from "./validation/flowValidation"; import { validateFlow } from "./validation/flowValidation";
type Page = "tasks" | "editor" | "runs" | "settings"; type Page = "tasks" | "editor" | "runs" | "settings" | "visionLab";
interface ToastState { interface ToastState {
id: number; id: number;
@@ -102,12 +103,11 @@ export function App() {
return ( return (
<div className="loading"> <div className="loading">
<Loader2 className="icon spin" style={{ width: 24, height: 24 }} /> <Loader2 className="icon spin" style={{ width: 24, height: 24 }} />
正在加载 1016 项组件目录… 正在加载组件目录…
</div> </div>
); );
} }
const executable = catalog.components.filter((item) => item.implemented).length;
const refreshFlows = async () => { const refreshFlows = async () => {
try { try {
@@ -213,7 +213,7 @@ export function App() {
本地执行 · 离线 本地执行 · 离线
</span> </span>
<span className="engine-chip"> <span className="engine-chip">
{catalog.counts.total} 目录 · {executable} 可执行 {catalog.counts.total} 个可编排组件
</span> </span>
</div> </div>
</header> </header>
@@ -235,6 +235,14 @@ export function App() {
<railIcons.settings className="icon" /> <railIcons.settings className="icon" />
设置 设置
</button> </button>
<button
type="button"
className={`rail-item${page === "visionLab" ? " active" : ""}`}
onClick={() => setPage("visionLab")}
>
<Microscope className="icon" />
实验台
</button>
<span className="rail-spacer" /> <span className="rail-spacer" />
<div className="rail-user"> <div className="rail-user">
<span className="rail-avatar">RA</span> <span className="rail-avatar">RA</span>
@@ -259,6 +267,7 @@ export function App() {
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />} {page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
{page === "runs" && <RunsPage onBack={() => { setPage("tasks"); void refreshFlows(); }} />} {page === "runs" && <RunsPage onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
{page === "settings" && <SettingsPage catalog={catalog} />} {page === "settings" && <SettingsPage catalog={catalog} />}
{page === "visionLab" && <VisionLabPage />}
</main> </main>
{toast && ( {toast && (
@@ -272,3 +281,4 @@ export function App() {
} }
+1 -4
View File
@@ -60,7 +60,6 @@ export function ContainerNode({ data, selected }: NodeProps) {
const removeNode = useEditorStore((state) => state.removeNode); const removeNode = useEditorStore((state) => state.removeNode);
const moveChild = useEditorStore((state) => state.moveChild); const moveChild = useEditorStore((state) => state.moveChild);
const Icon = groupIcon(component.group); const Icon = groupIcon(component.group);
const pending = !component.implemented;
return ( return (
<div <div
@@ -68,7 +67,6 @@ export function ContainerNode({ data, selected }: NodeProps) {
"container-node", "container-node",
`tone-${component.group}`, `tone-${component.group}`,
selected ? "selected" : "", selected ? "selected" : "",
pending ? "pending" : "",
nodeData.status && nodeData.status !== "idle" ? nodeData.status : "" nodeData.status && nodeData.status !== "idle" ? nodeData.status : ""
] ]
.filter(Boolean) .filter(Boolean)
@@ -84,7 +82,6 @@ export function ContainerNode({ data, selected }: NodeProps) {
<span className="container-kicker">容器 · {component.id}</span> <span className="container-kicker">容器 · {component.id}</span>
<strong>{node.label || component.name}</strong> <strong>{node.label || component.name}</strong>
</span> </span>
{pending && <span className="pending-badge">待接入</span>}
</div> </div>
<div className="container-body"> <div className="container-body">
@@ -100,7 +97,7 @@ export function ContainerNode({ data, selected }: NodeProps) {
{children.map((child, index) => { {children.map((child, index) => {
const childComponent = components.get(child.componentId); const childComponent = components.get(child.componentId);
const ChildIcon = groupIcon(childComponent?.group || "data"); const ChildIcon = groupIcon(childComponent?.group || "data");
const childPending = childComponent ? !childComponent.implemented : true; const childPending = false;
return ( return (
<div <div
key={child.id} key={child.id}
-1
View File
@@ -116,7 +116,6 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
fields: [], fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [], slots: [],
implemented: false
}; };
return { return {
id: node.id, id: node.id,
+1 -4
View File
@@ -15,7 +15,6 @@ export function RpaNode({ data, selected }: NodeProps) {
const nodeData = data as RpaNodeData; const nodeData = data as RpaNodeData;
const component = nodeData.component; const component = nodeData.component;
const Icon = groupIcon(component.group); const Icon = groupIcon(component.group);
const pending = !component.implemented;
return ( return (
<div <div
@@ -23,7 +22,6 @@ export function RpaNode({ data, selected }: NodeProps) {
"rpa-node", "rpa-node",
`tone-${component.group}`, `tone-${component.group}`,
selected ? "selected" : "", selected ? "selected" : "",
pending ? "pending" : "",
nodeData.status && nodeData.status !== "idle" ? nodeData.status : "" nodeData.status && nodeData.status !== "idle" ? nodeData.status : ""
] ]
.filter(Boolean) .filter(Boolean)
@@ -36,8 +34,7 @@ export function RpaNode({ data, selected }: NodeProps) {
<span className="rpa-node-copy"> <span className="rpa-node-copy">
<span className="rpa-node-top"> <span className="rpa-node-top">
<span className="rpa-node-kind">{component.kind === "native" ? "控制" : component.kind === "trigger" ? "触发" : component.group}</span> <span className="rpa-node-kind">{component.kind === "native" ? "控制" : component.kind === "trigger" ? "触发" : component.group}</span>
{pending && <span className="pending-badge">待接入</span>} {nodeData.outputVar && <span className="rpa-node-var">{nodeData.outputVar}</span>}
{!pending && nodeData.outputVar && <span className="rpa-node-var">{nodeData.outputVar}</span>}
</span> </span>
<span className="rpa-node-title">{nodeData.label || component.name}</span> <span className="rpa-node-title">{nodeData.label || component.name}</span>
<span className="rpa-node-sub">{nodeData.subtitle || component.description || component.id}</span> <span className="rpa-node-sub">{nodeData.subtitle || component.description || component.id}</span>
+1 -1
View File
@@ -251,7 +251,7 @@ export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
<div className="canvas-badge"> <div className="canvas-badge">
<Check className="icon" /> <Check className="icon" />
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项 组件 {catalog.counts.total} 项
</div> </div>
<RunDrawer /> <RunDrawer />
+17 -14
View File
@@ -14,7 +14,7 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
const [waitDraft, setWaitDraft] = useState("5000"); const [waitDraft, setWaitDraft] = useState("5000");
const [waitSaving, setWaitSaving] = useState(false); const [waitSaving, setWaitSaving] = useState(false);
const [waitMessage, setWaitMessage] = useState<string | null>(null); const [waitMessage, setWaitMessage] = useState<string | null>(null);
const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "" }); const [aiDraft, setAiDraft] = useState({ baseUrl: "", apiKey: "", model: "", visionModel: "" });
const [aiSaving, setAiSaving] = useState(false); const [aiSaving, setAiSaving] = useState(false);
const [aiMessage, setAiMessage] = useState<string | null>(null); const [aiMessage, setAiMessage] = useState<string | null>(null);
@@ -24,7 +24,12 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
void window.rpa.settings.get().then((value) => { void window.rpa.settings.get().then((value) => {
setSettings(value); setSettings(value);
setWaitDraft(String(value.browserElementWaitMs)); setWaitDraft(String(value.browserElementWaitMs));
setAiDraft(value.aiConfig ?? { baseUrl: "", apiKey: "", model: "" }); setAiDraft({
baseUrl: value.aiConfig?.baseUrl ?? "",
apiKey: value.aiConfig?.apiKey ?? "",
model: value.aiConfig?.model ?? "",
visionModel: value.aiConfig?.visionModel ?? ""
});
}).catch(() => setSettings(null)); }).catch(() => setSettings(null));
}, []); }, []);
@@ -62,12 +67,6 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
}; };
const executable = catalog.components.filter((item) => item.implemented).length;
const groups = useMemo(() => catalog.groups.map((group) => {
const components = catalog.components.filter((component) => component.group === group.id);
const connected = components.filter((component) => component.implemented).length;
return { ...group, connected, pending: components.length - connected };
}), [catalog]);
return ( return (
<div className="settings"> <div className="settings">
@@ -85,24 +84,23 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
<SettingCard icon={Info} title="版本与运行环境"> <SettingCard icon={Info} title="版本与运行环境">
<dl className="kv"><dt>应用版本</dt><dd>{info?.version || "0.1.0"}</dd><dt>Electron / Chromium</dt><dd>{info?.electron || "-"} / {info?.chrome || "-"}</dd><dt>Node</dt><dd>{info?.node || "-"}</dd><dt>运行模式</dt><dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd><dt>数据目录</dt><dd>{info?.userData || "-"}</dd><dt>目录文件</dt><dd>{info?.catalogPath || "-"}</dd></dl> <dl className="kv"><dt>应用版本</dt><dd>{info?.version || "0.1.0"}</dd><dt>Electron / Chromium</dt><dd>{info?.electron || "-"} / {info?.chrome || "-"}</dd><dt>Node</dt><dd>{info?.node || "-"}</dd><dt>运行模式</dt><dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd><dt>数据目录</dt><dd>{info?.userData || "-"}</dd><dt>目录文件</dt><dd>{info?.catalogPath || "-"}</dd></dl>
</SettingCard> </SettingCard>
<SettingCard icon={FolderCog} title="产品边界"><p className="field-hint setting-copy">组件元数据来自项目内 1016 项目录。移动端、机械臂、U 盾、远程桌面、SAP/IE 专用通道、云服务、数据库和 FTP 保持待接入,不随当前版本承诺执行。</p></SettingCard> <SettingCard icon={FolderCog} title="产品边界"><p className="field-hint setting-copy">组件目录按业务场景组织;专用硬件、厂商私有通道和明确不接入能力已分离备份,不再作为可编排组件展示。</p></SettingCard>
</> </>
)} )}
{tab === "catalog" && ( {tab === "catalog" && (
<> <>
<SettingCard icon={Database} title="组件接入概览"> <SettingCard icon={Database} title="组件目录">
<dl className="kv"><dt>目录总数</dt><dd>{catalog.counts.total} 项</dd><dt>已接入执行器</dt><dd>{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)</dd><dt>待接入</dt><dd>{catalog.counts.total - executable} 项</dd><dt>注册处理器 ID</dt><dd>{handlers?.count ?? "—"}</dd></dl> <dl className="kv"><dt>可编排组件</dt><dd>{catalog.counts.total} 项</dd><dt>注册处理器 ID</dt><dd>{handlers?.count ?? "—"}</dd><dt>备份清单</dt><dd>resources/excluded.json</dd></dl>
</SettingCard> </SettingCard>
<div className="setting-card"><h3><Database className="icon" />分类覆盖</h3><div className="setting-group-table">{groups.map((group) => <div key={group.id}><strong>{group.label}</strong><span>{group.connected} 已接入</span><span>{group.pending} 待接入</span><progress max={group.count} value={group.connected} /></div>)}</div></div>
</> </>
)} )}
{tab === "execution" && ( {tab === "execution" && (
<> <>
<SettingCard icon={Cpu} title="执行方式"><dl className="kv"><dt>执行位置</dt><dd>本机主进程</dd><dt>浏览器</dt><dd>独立 Electron 运行窗口 + CDP</dd><dt>桌面</dt><dd>PowerShell / user32 本地桥</dd><dt>Office</dt><dd>ExcelJS / PDF.js / pdf-lib / Word COM</dd><dt>运行前检查</dt><dd>待接入、必填、多出口、断链</dd></dl></SettingCard> <SettingCard icon={Cpu} title="执行方式"><dl className="kv"><dt>执行位置</dt><dd>本机主进程</dd><dt>浏览器</dt><dd>独立 Electron 运行窗口 + CDP</dd><dt>桌面</dt><dd>PowerShell / user32 本地桥</dd><dt>Office</dt><dd>ExcelJS / PDF.js / pdf-lib / Word COM</dd><dt>运行前检查</dt><dd>必填参数、多出口、断链</dd></dl></SettingCard>
<SettingCard icon={HardDrive} title="数据与证据"><p className="field-hint setting-copy">流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/&lt;runId&gt;/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</p></SettingCard> <SettingCard icon={HardDrive} title="数据与证据"><p className="field-hint setting-copy">流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/&lt;runId&gt;/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</p></SettingCard>
<SettingCard icon={ShieldCheck} title="安全边界"><p className="field-hint setting-copy">运行期默认离线。桌面动作只在流程显式运行时执行;未接入能力不会静默成功,而是在运行前检查或节点执行时返回明确错误。</p></SettingCard> <SettingCard icon={ShieldCheck} title="安全边界"><p className="field-hint setting-copy">运行期默认离线。桌面动作只在流程显式运行时执行;无效配置或外部服务不可用时会在节点执行时返回明确错误。</p></SettingCard>
</> </>
)} )}
@@ -152,6 +150,11 @@ export function SettingsPage({ catalog }: { catalog: Catalog }) {
<label className="field-label" htmlFor="ai-model">模型名称</label> <label className="field-label" htmlFor="ai-model">模型名称</label>
<input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" /> <input id="ai-model" className="text-input" value={aiDraft.model} onChange={(event) => setAiDraft({ ...aiDraft, model: event.target.value })} placeholder="gpt-4o-mini / deepseek-chat / qwen-plus" />
</div> </div>
<div className="field">
<label className="field-label" htmlFor="ai-vision-model">视觉模型名称</label>
<input id="ai-vision-model" className="text-input" value={aiDraft.visionModel ?? ""} onChange={(event) => setAiDraft({ ...aiDraft, visionModel: event.target.value })} placeholder="gpt-4o-mini / qwen-vl-plus / 留空复用模型名称" />
<span className="field-hint">桌面 AI 拾取必须使用多模态视觉模型,并会上传鼠标附近的局部截图。</span>
</div>
<div className="field-row"> <div className="field-row">
<button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}> <button type="button" className="btn btn-primary" onClick={() => void saveAiConfig()} disabled={aiSaving}>
<Save className="icon" /> <Save className="icon" />
+10 -35
View File
@@ -5,6 +5,7 @@ import {
CircleAlert, CircleAlert,
CirclePlay, CirclePlay,
Clock3, Clock3,
History,
ListChecks, ListChecks,
Pencil, Pencil,
Plus, Plus,
@@ -44,8 +45,6 @@ function formatDuration(value?: number): string {
return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} s`; return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} s`;
} }
const RUN_LABEL = { succeeded: "成功", failed: "失败", cancelled: "已取消", never: "未运行" } as const;
export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) { export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onRun, onDelete, onRefresh, onToast }: TasksPageProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null); const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
@@ -129,9 +128,9 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
<div className="task-table-head" aria-hidden="true"> <div className="task-table-head" aria-hidden="true">
<span>任务</span> <span>任务</span>
<span>状态</span>
<span>触发方式</span> <span>触发方式</span>
<span>最近运行</span> <span>最近运行</span>
<span>更新时间</span>
<span /> <span />
</div> </div>
@@ -151,14 +150,15 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
<span className="task-icon"><ListChecks className="icon" /></span> <span className="task-icon"><ListChecks className="icon" /></span>
<span className="task-copy"><strong>{flow.name}</strong><small>{flow.nodeCount} 个节点</small></span> <span className="task-copy"><strong>{flow.name}</strong><small>{flow.nodeCount} 个节点</small></span>
</div> </div>
<span className={`task-status ${flow.enabled === false ? "paused" : "enabled"}`}>
<span className="status-dot" />{flow.enabled === false ? "未启用" : "已启用"}
</span>
<span className="task-trigger"><CalendarClock className="icon" />{flow.triggerLabel || "手动运行"}</span> <span className="task-trigger"><CalendarClock className="icon" />{flow.triggerLabel || "手动运行"}</span>
<span className={`task-updated run-result ${flow.lastRunStatus || "never"}`} title={flow.lastRunError || ""}> <span className={`task-updated run-result ${flow.lastRunStatus || "never"}`} title={flow.lastRunError || ""}>
<Clock3 className="icon" /> <Clock3 className="icon" />
<span>{flow.lastRunAt ? formatTime(flow.lastRunAt) : "尚未运行"}{flow.lastRunDurationMs !== undefined ? ` · ${formatDuration(flow.lastRunDurationMs)}` : ""}</span> <span>{flow.lastRunAt ? formatTime(flow.lastRunAt) : "尚未运行"}{flow.lastRunDurationMs !== undefined ? ` · ${formatDuration(flow.lastRunDurationMs)}` : ""}</span>
</span> </span>
<span className="task-updated" title={`最近修改于 ${formatTime(flow.updatedAt)}`}>
<History className="icon" />
<span>{formatTime(flow.updatedAt)}</span>
</span>
<span className="task-row-actions"> <span className="task-row-actions">
<button type="button" className="icon-btn" title="运行任务" onClick={(event) => { event.stopPropagation(); onRun(flow.id); }}> <button type="button" className="icon-btn" title="运行任务" onClick={(event) => { event.stopPropagation(); onRun(flow.id); }}>
<CirclePlay className="icon" /> <CirclePlay className="icon" />
@@ -172,38 +172,13 @@ export function TasksPage({ flows, onCreate, onCreateExamples, onOpenEditor, onR
</span> </span>
</div> </div>
))} ))}
{!filtered.length && ( {!flows.length ? (
<div className="task-empty"><ListChecks className="icon" /><strong>还没有任务</strong><span>新建一个任务开始搭建自动化流程。</span><button type="button" className="btn btn-primary" onClick={onCreate}><Plus className="icon" />新建任务</button></div>
) : !filtered.length ? (
<div className="task-empty"><ListChecks className="icon" /><strong>没有匹配的任务</strong><span>调整搜索条件,或新建一个自动化任务。</span></div> <div className="task-empty"><ListChecks className="icon" /><strong>没有匹配的任务</strong><span>调整搜索条件,或新建一个自动化任务。</span></div>
)} ) : null}
</div> </div>
</section> </section>
<aside className="panel task-overview" aria-label="任务概览">
{selected ? (
<>
<div className="task-overview-head">
<span className="task-icon large"><ListChecks className="icon" /></span>
<div><span className="eyebrow">任务概览</span><h2>{selected.name}</h2></div>
</div>
<div className="overview-status"><span className="status-dot" />{selected.enabled === false ? "未启用" : "已启用"}<span className="overview-sep" />本地执行</div>
<div className="overview-actions">
<button type="button" className="btn btn-primary" onClick={() => onRun(selected.id)}><CirclePlay className="icon" />运行任务</button>
<button type="button" className="btn" onClick={() => onOpenEditor(selected.id)}><Pencil className="icon" />编辑流程</button>
</div>
<dl className="overview-meta">
<div><dt>触发方式</dt><dd><CalendarClock className="icon" />{selected.triggerLabel || "手动运行"}</dd></div>
<div><dt>流程节点</dt><dd>{selected.nodeCount} 个</dd></div>
<div><dt>最近运行</dt><dd className={`overview-run ${selected.lastRunStatus || "never"}`}>{RUN_LABEL[selected.lastRunStatus || "never"]} · {selected.lastRunAt ? formatTime(selected.lastRunAt) : "尚未运行"}</dd></div>
<div><dt>运行耗时</dt><dd>{selected.lastRunDurationMs === undefined ? "—" : formatDuration(selected.lastRunDurationMs)}</dd></div>
{selected.lastRunError && <div><dt>失败原因</dt><dd className="overview-error" title={selected.lastRunError}>{selected.lastRunError}</dd></div>}
<div><dt>最近修改</dt><dd>{formatTime(selected.updatedAt)}</dd></div>
</dl>
<div className="overview-note"><Zap className="icon" /><span>任务打开后先查看概览;只有点击“编辑流程”才进入画布。</span></div>
</>
) : (
<div className="task-empty overview-empty"><ListChecks className="icon" /><strong>还没有任务</strong><span>新建一个任务开始搭建自动化流程。</span><button type="button" className="btn btn-primary" onClick={onCreate}><Plus className="icon" />新建任务</button></div>
)}
</aside>
</div> </div>
</div> </div>
); );
+232
View File
@@ -0,0 +1,232 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FolderOpen, Play, Pause, RefreshCw, Sparkles } from "lucide-react";
import type { VisionLabDetection, VisionLabSource } from "@shared/types";
function fileName(value: string): string {
const normalized = value.replace(/[\\/]+$/, "");
const index = Math.max(normalized.lastIndexOf("\\"), normalized.lastIndexOf("/"));
return index >= 0 ? normalized.slice(index + 1) : normalized;
}
export function VisionLabPage() {
const [sources, setSources] = useState<VisionLabSource[]>([]);
const [sourceId, setSourceId] = useState("");
const [defaultModel, setDefaultModel] = useState("");
const [customModel, setCustomModel] = useState<string | null>(null);
const [score, setScore] = useState(0.25);
const [overlap, setOverlap] = useState(0.42);
const [intervalMs, setIntervalMs] = useState(600);
const [continuous, setContinuous] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [detection, setDetection] = useState<VisionLabDetection | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const runTokenRef = useRef(0);
const selectedSource = sources.find(source => source.id === sourceId);
const activeModel = customModel || defaultModel;
const refreshSources = useCallback(async () => {
try {
const next = await window.rpa.visionLab.sources();
setSources(next);
setSourceId(current => next.some(source => source.id === current) ? current : next[0]?.id || "");
setError(null);
} catch (refreshError) {
setError(refreshError instanceof Error ? refreshError.message : String(refreshError));
}
}, []);
useEffect(() => {
void refreshSources();
void window.rpa.visionLab.defaultModel().then(setDefaultModel).catch(error => {
setError(error instanceof Error ? error.message : String(error));
});
}, [refreshSources]);
const detectOnce = useCallback(async () => {
if (!sourceId) return;
setBusy(true);
try {
const result = await window.rpa.visionLab.detect({
sourceId,
modelPath: customModel || undefined,
scoreThreshold: score,
iouThreshold: overlap
});
setDetection(result);
setError(null);
} catch (detectError) {
setError(detectError instanceof Error ? detectError.message : String(detectError));
} finally {
setBusy(false);
}
}, [customModel, overlap, score, sourceId]);
useEffect(() => {
if (!continuous || !sourceId) return;
const token = ++runTokenRef.current;
let alive = true;
const tick = async () => {
while (alive) {
const started = Date.now();
try {
const result = await window.rpa.visionLab.detect({
sourceId,
modelPath: customModel || undefined,
scoreThreshold: score,
iouThreshold: overlap
});
if (!alive || token !== runTokenRef.current) return;
setDetection(result);
setError(null);
} catch (detectError) {
if (!alive || token !== runTokenRef.current) return;
setError(detectError instanceof Error ? detectError.message : String(detectError));
}
const wait = Math.max(80, intervalMs - (Date.now() - started));
await new Promise(resolve => setTimeout(resolve, wait));
}
};
void tick();
return () => { alive = false; };
}, [continuous, customModel, intervalMs, overlap, score, sourceId]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas || !detection) return;
const image = new Image();
image.onload = () => {
canvas.width = detection.width;
canvas.height = detection.height;
const context = canvas.getContext("2d");
if (!context) return;
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
const scale = Math.max(1, detection.width / 1000);
context.lineWidth = 2 * scale;
context.strokeStyle = "#22d3ee";
context.font = `${Math.round(14 * scale)}px Microsoft YaHei, Arial, sans-serif`;
context.textBaseline = "top";
for (const box of detection.boxes) {
context.strokeRect(box.x, box.y, box.width, box.height);
const label = `${box.category} ${Math.round(box.confidence * 100)}%`;
const metrics = context.measureText(label);
const labelWidth = metrics.width + 8 * scale;
const labelHeight = 19 * scale;
const labelY = box.y >= labelHeight + 2 * scale ? box.y - labelHeight - 2 * scale : box.y + 2 * scale;
context.fillStyle = "#0e7490";
context.fillRect(box.x, labelY, labelWidth, labelHeight);
context.fillStyle = "#ffffff";
context.fillText(label, box.x + 4 * scale, labelY + 3 * scale);
}
};
image.src = detection.image;
}, [detection]);
const chooseModel = async () => {
const selected = await window.rpa.dialog.open({
title: "选择 ONNX UI 检测模型",
filters: [{ name: "ONNX 模型", extensions: ["onnx"] }]
});
if (selected) setCustomModel(selected);
};
const modelLabel = useMemo(() => customModel ? fileName(customModel) : "内置 OmniParser v2", [customModel]);
const boxes = detection?.boxes || [];
return (
<div className="vision-lab">
<aside className="panel vision-lab-side">
<div className="panel-head">
<strong><Sparkles className="icon" /> AI 识别实验台</strong>
<span className="panel-subtitle">持续识别 · 选窗口 · 选模型</span>
</div>
<div className="vision-block">
<label className="field-label" htmlFor="vision-source">识别目标</label>
<div className="vision-row">
<select
id="vision-source"
className="text-input"
value={sourceId}
onChange={event => setSourceId(event.target.value)}
>
{!sources.length && <option value="">正在读取窗口…</option>}
{sources.map(source => (
<option key={source.id} value={source.id}>
{source.kind === "screen" ? "屏幕 · " : "窗口 · "}{source.name}
</option>
))}
</select>
<button className="btn icon-btn" type="button" onClick={() => void refreshSources()} title="刷新窗口列表">
<RefreshCw className="icon" />
</button>
</div>
{selectedSource && (
<div className="source-preview">
<img src={selectedSource.thumbnail} alt={selectedSource.name} />
</div>
)}
</div>
<div className="vision-block">
<span className="field-label">识别模型</span>
<div className="vision-row">
<button
type="button"
className={`btn${customModel ? "" : " btn-primary"}`}
onClick={() => setCustomModel(null)}
>
内置 OmniParser v2
</button>
<button type="button" className={`btn${customModel ? " btn-primary" : ""}`} onClick={() => void chooseModel()}>
<FolderOpen className="icon" />
选择 ONNX
</button>
</div>
<div className="model-path" title={activeModel || ""}>{activeModel || "模型未找到"}</div>
</div>
<div className="vision-block">
<label className="field-label" htmlFor="vision-score">置信度阈值:{score.toFixed(2)}</label>
<input id="vision-score" type="range" min={0.05} max={0.9} step={0.01} value={score}
onChange={event => setScore(Number(event.target.value))} />
<label className="field-label" htmlFor="vision-overlap">重叠抑制:{overlap.toFixed(2)}</label>
<input id="vision-overlap" type="range" min={0.1} max={0.85} step={0.01} value={overlap}
onChange={event => setOverlap(Number(event.target.value))} />
<label className="field-label" htmlFor="vision-interval">识别间隔:{intervalMs} ms</label>
<input id="vision-interval" type="range" min={100} max={3000} step={50} value={intervalMs}
onChange={event => setIntervalMs(Number(event.target.value))} />
</div>
<div className="vision-actions">
<button className="btn" type="button" onClick={() => void detectOnce()} disabled={!sourceId || busy}>
识别一次
</button>
<button className={`btn${continuous ? " btn-danger" : " btn-primary"}`} type="button"
onClick={() => setContinuous(value => !value)} disabled={!sourceId}>
{continuous ? <Pause className="icon" /> : <Play className="icon" />}
{continuous ? "停止连续" : "开始连续"}
</button>
</div>
{error && <div className="vision-error">{error}</div>}
</aside>
<section className="panel vision-lab-stage">
<div className="vision-stage-head">
<strong>{detection?.sourceName || selectedSource?.name || "未选择目标"}</strong>
<span>{boxes.length} 个候选 · 采集 {detection?.captureMs ?? "-"}ms · 推理 {detection?.detectMs ?? "-"}ms</span>
</div>
<div className="vision-stage">
{detection ? (
<canvas ref={canvasRef} className="vision-canvas" />
) : (
<div className="vision-empty">选择窗口和模型后开始识别</div>
)}
</div>
</section>
</div>
);
}
+5 -26
View File
@@ -3,8 +3,6 @@ import { ChevronDown, ChevronRight, Search, Sparkles } from "lucide-react";
import type { Catalog, CatalogComponent } from "@shared/types"; import type { Catalog, CatalogComponent } from "@shared/types";
import { groupIcon } from "../components/icons"; import { groupIcon } from "../components/icons";
type StatusFilter = "all" | "executable" | "pending";
interface PaletteProps { interface PaletteProps {
catalog: Catalog; catalog: Catalog;
onAdd: (componentId: string) => void; onAdd: (componentId: string) => void;
@@ -19,20 +17,13 @@ interface ComponentGroup {
export function Palette({ catalog, onAdd }: PaletteProps) { export function Palette({ catalog, onAdd }: PaletteProps) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [expanded, setExpanded] = useState<Set<string>>(() => new Set()); const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const executableCount = useMemo(
() => catalog.components.filter((item) => item.implemented).length,
[catalog]
);
const pendingCount = catalog.components.length - executableCount;
const filtered = useMemo(() => { const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase(); const keyword = query.trim().toLowerCase();
return catalog.components.filter((component) => { return catalog.components.filter((component) => {
if (status === "executable" && !component.implemented) return false; if (component.duplicateOf) return false;
if (status === "pending" && component.implemented) return false;
if (!keyword) return true; if (!keyword) return true;
const haystack = [ const haystack = [
component.name, component.name,
@@ -65,13 +56,8 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
}, [catalog.groups, filtered]); }, [catalog.groups, filtered]);
useEffect(() => { useEffect(() => {
if (query.trim()) return; if (query.trim()) setExpanded(new Set(groups.map((group) => group.id)));
setExpanded((current) => current.size ? current : new Set(catalog.groups.slice(0, 2).map((group) => group.id))); else setExpanded(new Set());
}, [catalog.groups, query]);
useEffect(() => {
if (!query.trim()) return;
setExpanded(new Set(groups.map((group) => group.id)));
}, [groups, query]); }, [groups, query]);
const allExpanded = groups.length > 0 && groups.every((group) => expanded.has(group.id)); const allExpanded = groups.length > 0 && groups.every((group) => expanded.has(group.id));
@@ -106,12 +92,6 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
/> />
</label> </label>
<div className="seg" role="tablist" aria-label="组件状态筛选">
<button type="button" className={status === "all" ? "active" : ""} onClick={() => setStatus("all")}>全部</button>
<button type="button" className={status === "executable" ? "active" : ""} onClick={() => setStatus("executable")}>可执行 {executableCount}</button>
<button type="button" className={status === "pending" ? "active" : ""} onClick={() => setStatus("pending")}>待接入 {pendingCount}</button>
</div>
<button type="button" className="palette-expand-all" onClick={toggleAll}> <button type="button" className="palette-expand-all" onClick={toggleAll}>
{allExpanded ? <ChevronDown className="icon" /> : <ChevronRight className="icon" />} {allExpanded ? <ChevronDown className="icon" /> : <ChevronRight className="icon" />}
{allExpanded ? "收起全部" : "展开全部分类"} {allExpanded ? "收起全部" : "展开全部分类"}
@@ -142,7 +122,7 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
)} )}
</div> </div>
<div className="palette-more"><ChevronDown className="icon" style={{ width: 12, height: 12 }} />拖拽叶子组件到画布;灰点为待接入。</div> <div className="palette-more"><ChevronDown className="icon" style={{ width: 12, height: 12 }} />拖拽叶子组件到画布即可配置流程。</div>
</aside> </aside>
); );
} }
@@ -153,7 +133,7 @@ function PaletteRow({ component, onAdd }: { component: CatalogComponent; onAdd:
<button <button
type="button" type="button"
draggable draggable
className={`palette-row${component.implemented ? "" : " pending"}`} className="palette-row"
data-group={component.group} data-group={component.group}
onDragStart={(event) => { onDragStart={(event) => {
event.dataTransfer.setData("application/rpa-component", component.id); event.dataTransfer.setData("application/rpa-component", component.id);
@@ -164,7 +144,6 @@ function PaletteRow({ component, onAdd }: { component: CatalogComponent; onAdd:
> >
<span className="palette-icon"><Icon className="icon" /></span> <span className="palette-icon"><Icon className="icon" /></span>
<span className="palette-copy"><span className="palette-name">{component.name}</span><span className="palette-id">{component.id}</span></span> <span className="palette-copy"><span className="palette-name">{component.name}</span><span className="palette-id">{component.id}</span></span>
<span className={`status-dot${component.implemented ? "" : " pending"}`} />
</button> </button>
); );
} }
+358 -51
View File
@@ -199,7 +199,6 @@ export function Properties({ catalog, onToast }: PropertiesProps) {
</div> </div>
<div className="props-foot"> <div className="props-foot">
<span>{component.implemented ? "已接入执行器" : "待接入执行器"}</span>
</div> </div>
</> </>
) : ( ) : (
@@ -256,15 +255,7 @@ function PropertyTab({
return ( return (
<> <>
{!component.implemented && (
<div className="notice warn">
<AlertTriangle className="icon" />
<div>
<strong>当前版本无执行器</strong>
<span>可保存与连线;运行到该节点会停止并给出替代建议。</span>
</div>
</div>
)}
{parent && ( {parent && (
<div className="notice"> <div className="notice">
@@ -447,6 +438,64 @@ function PropertyTab({
); );
} }
interface FileFilterOption {
name: string;
extensions: string[];
}
/**
* 按组件与字段上下文推导文件选择对话框的类型过滤器。
* 先匹配字段本身的含义(字段键/标签),再匹配组件类别;无法识别时退回「全部文件」。
*/
function fileFiltersFor(componentId: string, field: CatalogField): FileFilterOption[] {
const haystack = `${componentId} ${field.key} ${field.label ?? ""}`;
const filters: FileFilterOption[] = [];
if (/excel|bivartable|databook|工作簿|表格/i.test(haystack)) {
filters.push({ name: "Excel 表格", extensions: ["xlsx", "xlsm", "xls", "csv"] });
}
if (/word|文档/i.test(haystack)) {
filters.push({ name: "Word 文档", extensions: ["docx", "doc"] });
}
if (/pdf/i.test(haystack)) {
filters.push({ name: "PDF 文档", extensions: ["pdf"] });
}
if (/image|ocr|CV_|vision|图片|图像|截图|模板|照片/i.test(haystack)) {
filters.push({ name: "图片文件", extensions: ["png", "jpg", "jpeg", "bmp", "gif", "webp"] });
}
if (/zip|compress|archive|压缩/i.test(haystack)) {
filters.push({ name: "压缩文件", extensions: ["zip", "rar", "7z", "gz"] });
}
if (/audio|speech|音频|语音|录音/i.test(haystack)) {
filters.push({ name: "音频文件", extensions: ["wav", "mp3", "flac", "ogg"] });
}
if (/video|视频/i.test(haystack)) {
filters.push({ name: "视频文件", extensions: ["mp4", "avi", "mkv", "mov"] });
}
if (/json/i.test(haystack)) {
filters.push({ name: "JSON 文件", extensions: ["json"] });
}
if (!filters.length) {
return [{ name: "全部文件", extensions: ["*"] }];
}
filters.push({ name: "全部文件", extensions: ["*"] });
return filters;
}
/** 输出字段的保存框默认文件名:取第一个非「全部文件」过滤器的首个扩展名 */
function defaultSaveFilename(filters: FileFilterOption[]): string {
const ext = filters.find((f) => !f.extensions.includes("*"))?.extensions[0];
return ext ? `未命名.${ext}` : "";
}
/** 保存结果没有扩展名时补上所选类型的扩展名(Windows 保存框不会自动补) */
function ensureSaveExtension(filePath: string, filters: FileFilterOption[]): string {
const exts = filters.find((f) => !f.extensions.includes("*"))?.extensions ?? [];
if (!exts.length) return filePath;
const clean = filePath.trim();
if (/\.[A-Za-z0-9]+$/.test(clean)) return clean;
return `${clean}.${exts[0]}`;
}
function FieldControl({ function FieldControl({
field, field,
node, node,
@@ -462,6 +511,8 @@ function FieldControl({
}) { }) {
const raw = node.values[field.key] ?? field.defaultValue; const raw = node.values[field.key] ?? field.defaultValue;
const [picking, setPicking] = useState(false); const [picking, setPicking] = useState(false);
const [windowList, setWindowList] = useState<{ title: string; process?: string }[]>([]);
const [windowPickerField, setWindowPickerField] = useState<string | null>(null);
const isGroundTarget = node.componentId.endsWith("Ground"); const isGroundTarget = node.componentId.endsWith("Ground");
const isVisionSource = /^(OcrOffline_|CV_|Image_)/.test(node.componentId); const isVisionSource = /^(OcrOffline_|CV_|Image_)/.test(node.componentId);
const isWindowTarget = /^(Window_|System_ConnectRemoteDesktop)/.test(node.componentId); const isWindowTarget = /^(Window_|System_ConnectRemoteDesktop)/.test(node.componentId);
@@ -492,15 +543,30 @@ function FieldControl({
const pickPath = async (directories: boolean) => { const pickPath = async (directories: boolean) => {
try { try {
if (!directories) {
// 输出类字段(保存/输出/目标文件等)指向将来才生成的文件,用「保存」对话框允许输入新文件名;
// 输入类字段保持「打开」对话框,只能选择已存在的文件。
const isOutputField = /输出|保存|另存|导出|目标文件|文件名|output|save|export|dest|target/i.test(
`${field.key} ${field.label ?? ""}`
);
if (isOutputField) {
const filters = fileFiltersFor(node.componentId, field);
// 预填默认文件名(按过滤器推断扩展名),避免弹出的保存框文件名为空没法直接点保存
const defaultPath = textOf(raw) || defaultSaveFilename(filters);
const selected = await window.rpa.dialog.save({
title: field.label,
defaultPath: defaultPath || undefined,
filters
});
// Windows 保存框不会按所选类型自动补扩展名,这里补上
if (selected) onChange(field.key, ensureSaveExtension(selected, filters));
return;
}
}
const selected = await window.rpa.dialog.open({ const selected = await window.rpa.dialog.open({
title: field.label, title: field.label,
directories, directories,
filters: directories filters: directories ? undefined : fileFiltersFor(node.componentId, field)
? undefined
: [
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
{ name: "全部文件", extensions: ["*"] }
]
}); });
if (selected) onChange(field.key, selected); if (selected) onChange(field.key, selected);
} catch (error) { } catch (error) {
@@ -515,12 +581,24 @@ function FieldControl({
const currentFlow = useEditorStore.getState().flow; const currentFlow = useEditorStore.getState().flow;
const nodeIndex = currentFlow?.nodes.findIndex((item) => item.id === node.id) ?? -1; const nodeIndex = currentFlow?.nodes.findIndex((item) => item.id === node.id) ?? -1;
const openNode = currentFlow && nodeIndex > 0 const openNode = currentFlow && nodeIndex > 0
? [...currentFlow.nodes.slice(0, nodeIndex)].reverse().find((item) => { ? [...currentFlow.nodes.slice(0, nodeIndex)].reverse().find((item) => /^WebBrowser_Create/.test(item.componentId))
const url = textOf(item.values.url).trim();
return Boolean(url && !url.startsWith("${"));
})
: undefined; : undefined;
const picked = await window.rpa.browser.pickElement({ url: textOf(openNode?.values.url) || undefined }); let browser;
if (openNode) {
const type = textOf(openNode.values.type).trim().toLowerCase();
const isRemote = ["1", "true", "yes"].includes(textOf(openNode.values.isRemote).trim().toLowerCase());
const port = Number(textOf(openNode.values.port) || 9222) || 9222;
if (isRemote) {
browser = { mode: "remote" as const, host: textOf(openNode.values.ipAddress) || "127.0.0.1", port };
} else if (type === "chrome" || type === "edge") {
const extraArgs = textOf(openNode.values.arguments).split(/\s+/).map((item) => item.trim()).filter(Boolean);
browser = { mode: "launch" as const, browserType: type === "edge" ? "edge" as const : "chrome" as const, port, extraArgs };
}
}
const picked = await window.rpa.browser.pickElement({
url: textOf(openNode?.values.url).trim() || undefined,
browser
});
onChange(field.key, picked.selector); onChange(field.key, picked.selector);
onToast(`已拾取 ${picked.tag}${picked.label ? ` · ${picked.label}` : ""}`); onToast(`已拾取 ${picked.tag}${picked.label ? ` · ${picked.label}` : ""}`);
} catch (error) { } catch (error) {
@@ -533,8 +611,18 @@ function FieldControl({
setPicking(true); setPicking(true);
try { try {
const region = await window.rpa.desktop.pickRegion(); const region = await window.rpa.desktop.pickRegion();
// 只有「搜索区域」类字段(displayType=region, 如 CV_* 的 searchRegion)存区域坐标 JSON;
// 「屏幕截图」类字段(displayType=screenShot, 如 Image_* 的图片路径)是把截好的图当模板图用,
// 运行时按文件路径打开该值——存 JSON 会直接 ENOENT, 所以这里存截图落盘后的路径
const wantsRegionJson = field.widget === "region" && field.displayType !== "screenShot";
if (wantsRegionJson) {
// region 字段存完整信息: 坐标+尺寸+截图路径, 运行时可据此定位区域内容在屏幕上的绝对位置
onChange(field.key, JSON.stringify({ x: region.x, y: region.y, width: region.width, height: region.height, imagePath: region.imagePath }));
onToast("已框选区域:" + region.width + "×" + region.height + " @(" + region.x + "," + region.y + ")");
} else {
onChange(field.key, region.imagePath); onChange(field.key, region.imagePath);
onToast("已框选并截图:" + region.width + "×" + region.height); onToast("已截取模板图片:" + region.width + "×" + region.height);
}
} catch (error) { } catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error"); onToast(error instanceof Error ? error.message : String(error), "error");
} finally { } finally {
@@ -550,14 +638,10 @@ function FieldControl({
onToast("未找到可见窗口", "error"); onToast("未找到可见窗口", "error");
return; return;
} }
const numbered = list.map((w: { title: string }, i: number) => (i + 1) + ". " + w.title).join("\n"); setWindowList(list as { title: string; process?: string }[]);
const choice = prompt("选择窗口(输入编号):\n" + numbered); setWindowPickerField(field.key);
const idx = Number(choice) - 1;
if (Number.isInteger(idx) && list[idx]) {
onChange(field.key, list[idx].title);
onToast("已选择窗口:" + list[idx].title);
} }
} catch (error) { catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error"); onToast(error instanceof Error ? error.message : String(error), "error");
} finally { } finally {
setPicking(false); setPicking(false);
@@ -565,21 +649,60 @@ function FieldControl({
}; };
const pickPointToField = async () => {
setPicking(true);
try {
const region = await window.rpa.desktop.pickRegion();
const cx = region.x + Math.floor(region.width / 2);
const cy = region.y + Math.floor(region.height / 2);
onChange(field.key, JSON.stringify({ x: cx, y: cy }));
onToast("已拾取坐标:" + cx + " , " + cy);
} catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error");
} finally {
setPicking(false);
}
};
if (field.widget === "boolean") { if (field.widget === "boolean") {
return ( return (
<label className="checkbox-row"> <label className="checkbox-row">
<input type="checkbox" checked={boolOf(raw)} onChange={(event) => onChange(field.key, event.target.checked)} /> <input type="checkbox" checked={boolOf(raw)} onChange={(event) => onChange(field.key, event.target.checked)} />
{field.label} {field.label}
{field.required && <span className="required">*</span>} {field.required && <span className="required">必填</span>}
</label> </label>
); );
} }
// 元数据缺描述时的兜底提示, 按控件类型与组件类别给出「该填什么」的指引
const fallbackHint =
field.widget === "element"
? isVisionTarget
? "填写屏幕上可见的目标文本,运行时截屏后通过 OCR 定位"
: isGroundTarget
? "填写桌面元素,或点「桌面拾取」自动获取"
: "填写元素选择器,或点「浏览器拾取」从页面获取"
: field.widget === "file"
? "填写完整文件路径,或点右侧按钮选择"
: field.widget === "folder"
? "填写完整文件夹路径,或点右侧按钮选择"
: field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect")
? "填写窗口标题,或点「选窗口」从可见窗口列表中选择"
: field.widget === "region"
? field.displayType === "screenShot"
? "填模板图片路径,或点「屏幕框选」截取屏幕上的目标图案"
: "填写区域坐标 JSON,或点「屏幕框选」生成"
: "";
const hint = field.description || fallbackHint;
return ( return (
<>
<div className="field"> <div className="field">
<label className="field-label"> <label className="field-label">
{field.label} {field.label}
{field.required && <span className="required">*</span>} {field.required && <span className="required">必填</span>}
{field.unit && <span className="field-hint">({field.unit})</span>} {field.unit && <span className="field-hint">({field.unit})</span>}
</label> </label>
@@ -624,6 +747,7 @@ function FieldControl({
<VarMenu variables={variables} onPick={insertVariable} /> <VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览"> <button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
<FolderOpen className="icon" style={{ width: 13, height: 13 }} /> <FolderOpen className="icon" style={{ width: 13, height: 13 }} />
</button>
{isVisionTarget && /image/i.test(field.key) && ( {isVisionTarget && /image/i.test(field.key) && (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕框选区域并截图"> <button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕框选区域并截图">
{picking ? "框选中" : "屏幕框选"} {picking ? "框选中" : "屏幕框选"}
@@ -634,36 +758,67 @@ function FieldControl({
{picking ? "选择中" : "选窗口"} {picking ? "选择中" : "选窗口"}
</button> </button>
)} )}
{isVisionTarget && /image/i.test(field.key) && (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕框选区域并截图">
{picking ? "框选中" : "屏幕框选"}
</button>
)}
{isWindowTarget && /title/i.test(field.key) && (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="选择窗口">
{picking ? "选择中" : "选窗口"}
</button>
)}
</button>
</div> </div>
) : field.widget === "element" ? ( ) : field.widget === "element" ? (
<div className="field-row"> <div className="field-row">
<input <input
className="text-input" className="text-input"
value={textOf(raw)} value={textOf(raw)}
placeholder="css:#submit / aria:button" placeholder={isVisionTarget ? "屏幕上可见的目标文本" : "css:#submit / aria:button"}
onChange={(event) => onChange(field.key, event.target.value)} onChange={(event) => onChange(field.key, event.target.value)}
/> />
{isGroundTarget ? ( {isGroundTarget ? (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)"> <button type="button" className="mini-btn" disabled={picking} onClick={() => void pickDesktop()} title="从桌面拾取元素(移到目标按空格)">
{picking ? "桌面拾取中" : "桌面拾取"} {picking ? "桌面拾取中" : "桌面拾取"}
</button> </button>
) : ( ) : isVisionTarget ? null : (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从运行浏览器拾取元素"> <button type="button" className="mini-btn" disabled={picking} onClick={() => void pickElement()} title="从浏览器拾取元素(移到目标按空格锁定,PageUp/PageDown 调层级)">
{picking ? "拾取中" : "拾取"} {picking ? "浏览器拾取中" : "浏览器拾取"}
</button> </button>
)} )}
</div> </div>
) : field.displayType === "colorPicker" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder="#FF0000" onChange={(e) => onChange(field.key, e.target.value)} />
<input type="color" value={/^#[0-9a-fA-F]{6}$/.test(textOf(raw)) ? textOf(raw) : "#000000"} style={{ width: 32, height: 28, padding: 0, border: "none", cursor: "pointer" }} onChange={(e) => onChange(field.key, e.target.value)} title="选择颜色" />
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "similaritySlider" ? (
<div className="field-row">
<input type="range" min={0} max={1} step={0.01} value={Number(textOf(raw)) || 0.8} style={{ flex: 1 }} onChange={(e) => onChange(field.key, e.target.value)} />
<input className="text-input" style={{ width: 72 }} type="number" min={0} max={1} step={0.01} value={textOf(raw)} placeholder="0.8" onChange={(e) => onChange(field.key, e.target.value)} />
</div>
) : field.displayType === "dateFormatSelector" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder="%Y-%m-%d %H:%M:%S" onChange={(e) => onChange(field.key, e.target.value)} />
<select className="select-input" style={{ width: 160 }} value="" onChange={(e) => { if (e.target.value) onChange(field.key, e.target.value); }}>
<option value="">选择常用格式…</option>
{["%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y/%m/%d", "%H:%M:%S", "%Y年%m月%d日", "%Y%m%d%H%M%S"].map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "variableTypeSelect" ? (
<select className="select-input" value={textOf(raw)} onChange={(e) => onChange(field.key, e.target.value)}>
<option value="">请选择类型</option>
{["rpa_str", "rpa_int", "rpa_double", "rpa_bool", "rpa_list", "rpa_dict", "rpa_datetimes", "rpa_any"].map((v) => (
<option key={v} value={v}>{v.replace("rpa_", "")}</option>
))}
</select>
) : field.displayType === "LLMModelSelect" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder="输入模型名称" onChange={(e) => onChange(field.key, e.target.value)} />
<select className="select-input" style={{ width: 180 }} value="" onChange={(e) => { if (e.target.value) onChange(field.key, e.target.value); }}>
<option value="">选择常用模型…</option>
{["gpt-4o", "gpt-4o-mini", "claude-3.5-sonnet", "deepseek-chat", "qwen-plus", "glm-4", "moonshot-v1-8k"].map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "databaseConfig" || field.displayType === "dictConfig" ? (
<textarea className="textarea-input" value={textOf(raw)} placeholder={field.placeholder || '{"key": "value"}'} rows={3} onChange={(e) => onChange(field.key, e.target.value)} />
) : field.widget === "secret" ? ( ) : field.widget === "secret" ? (
<div className="field-row"> <div className="field-row">
<input <input
@@ -683,23 +838,177 @@ function FieldControl({
placeholder={field.placeholder || ""} placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)} onChange={(event) => onChange(field.key, event.target.value)}
/> />
) : field.widget === "region" && (field.displayType === "captureWindow" || field.displayType === "openWindowSelect") ? (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "点右侧「选窗口」从可见窗口列表中选择"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从当前可见窗口列表中选择">
{picking ? "选择中" : "选窗口"}
</button>
</div>
) : field.widget === "region" && field.displayType === "screenShot" ? (
// 模板图片类字段(Image_* 的图片路径等): 值是文件路径, 框选是把截图存盘后填路径
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "填模板图片路径,或点「屏幕框选」截取"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" onClick={() => void pickPath(false)} title="浏览">
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
</button>
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="截取屏幕区域作为模板图片">
{picking ? "框选中" : "屏幕框选"}
</button>
</div>
) : field.widget === "region" && (field.displayType === "mouseRecord" || field.displayType === "clickPosition" || field.displayType === "clickPositionV2" || field.displayType === "clickPositionV3") ? (
// 坐标点类字段: 用户需要的是(x,y)坐标, 不是矩形区域, 用拾取坐标按钮
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "填坐标对象如 {\"x\":100,\"y\":200},或点「拾取坐标」在屏幕上点选"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickPointToField()} title="在屏幕上点击拾取一个坐标点">
{picking ? "拾取中" : "拾取坐标"}
</button>
</div>
) : field.widget === "region" && field.displayType === "excelRange" ? (
// Excel 区域字段: 值是 A1:B10 这样的单元格范围, 不是屏幕区域, 不应显示框选按钮
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "如 A1:B10 或 $A$1:$B$10"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.widget === "region" ? (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || "点右侧「屏幕框选」在屏幕上框选生成"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="在屏幕上框选一块区域">
{picking ? "框选中" : "屏幕框选"}
</button>
</div>
) : field.displayType === "USBGetComs" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder={field.placeholder || "如 COM3"} onChange={(e) => onChange(field.key, e.target.value)} />
<select className="select-input" style={{ width: 100 }} value="" onChange={(e) => { if (e.target.value) onChange(field.key, e.target.value); }}>
<option value="">选择串口…</option>
{["COM1","COM2","COM3","COM4","COM5","COM6","COM7","COM8"].map((v) => (<option key={v} value={v}>{v}</option>))}
</select>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "keyboardDriverSelect" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder={field.placeholder || "驱动名称"} onChange={(e) => onChange(field.key, e.target.value)} />
<select className="select-input" style={{ width: 120 }} value="" onChange={(e) => { if (e.target.value) onChange(field.key, e.target.value); }}>
<option value="">选择驱动…</option>
{["win32", "winio", "sendinput"].map((v) => (<option key={v} value={v}>{v}</option>))}
</select>
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "mailSelection" ? (
<div className="field-row">
<input className="text-input" type="email" value={textOf(raw)} placeholder={field.placeholder || "user@example.com"} onChange={(e) => onChange(field.key, e.target.value)} />
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "previewDataTable" ? (
<textarea className="textarea-input" value={textOf(raw)} readOnly disabled rows={2} placeholder="(运行时自动生成)" style={{ opacity: 0.5 }} />
) : field.displayType === "customModal" || field.displayType === "customModalV4" || field.displayType === "customModalLocal" ? (
<textarea className="textarea-input" value={textOf(raw)} placeholder={field.placeholder || "[{\"label\":\"\u5B57\u6BB5\u540D\",\"type\":\"text\",\"key\":\"field1\"}]"} rows={4} onChange={(e) => onChange(field.key, e.target.value)} />
) : field.displayType === "itemGroup" ? (
<textarea className="textarea-input" value={textOf(raw)} placeholder={field.placeholder || "筛选条件 JSON"} rows={3} onChange={(e) => onChange(field.key, e.target.value)} />
) : field.displayType === "datePicker" ? (
<div className="field-row">
<input className="text-input" type="date" value={textOf(raw)} onChange={(e) => onChange(field.key, e.target.value)} />
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "dateRangeFilter" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder={field.placeholder || "日期范围"} onChange={(e) => onChange(field.key, e.target.value)} />
<VarMenu variables={variables} onPick={insertVariable} />
</div>
) : field.displayType === "openWindowMultipleSelect" || field.displayType === "openWindowEditor" ? (
<div className="field-row">
<input className="text-input" value={textOf(raw)} placeholder={field.placeholder || "填窗口标题,或点右侧「选窗口」"} onChange={(e) => onChange(field.key, e.target.value)} />
<VarMenu variables={variables} onPick={insertVariable} />
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从可见窗口列表中选择">
{picking ? "选择中" : "选窗口"}
</button>
</div>
) : ( ) : (
<div className="field-row"> <div className="field-row">
<input <input
className="text-input" className="text-input"
value={textOf(raw)} value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")} placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)} onChange={(event) => onChange(field.key, event.target.value)}
/> />
<VarMenu variables={variables} onPick={insertVariable} /> <VarMenu variables={variables} onPick={insertVariable} />
{/* window_obj/窗口对象 等窗口标题语义字段直接给「选窗口」按钮(不依赖组件前缀);
其余 Window_ 系组件的字段仍按前缀+关键词匹配 */}
{(/^(window_obj|窗口对象|窗口标题|window_title|match_title)$/i.test(field.key) || (isWindowTarget && /window|title/i.test(field.key))) && (
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickWindowToField()} title="从当前可见窗口列表中选择">
{picking ? "选择中" : "选窗口"}
</button>
)}
{field.displayType === "imageSelector" && (
<>
<button type="button" className="mini-btn" onClick={() => void pickPath(false)} title="选择图片文件">
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
</button>
<button type="button" className="mini-btn" disabled={picking} onClick={() => void pickRegionToField()} title="屏幕截取区域作为图片">
{picking ? "截取中" : "屏幕截取"}
</button>
</>
)}
</div> </div>
)} )}
{field.description && <span className="field-hint">{field.description}</span>} {hint && <span className="field-hint field-desc">{hint}</span>}
{field.valueTypes?.length > 0 && ( {field.valueTypes?.length > 0 && (
<span className="field-hint mono">{field.valueTypes.join(" · ")}</span> <span className="field-hint mono">{field.valueTypes.join(" · ")}</span>
)} )}
</div> </div>
{windowPickerField && (
<div style={{ position: "fixed", inset: 0, zIndex: 9999, background: "rgba(0,0,0,0.4)", display: "flex", alignItems: "center", justifyContent: "center" }} onClick={() => setWindowPickerField(null)}>
<div style={{ background: "#fff", borderRadius: 8, padding: 16, minWidth: 360, maxHeight: 400, overflow: "auto", boxShadow: "0 4px 20px rgba(0,0,0,0.3)" }} onClick={(e) => e.stopPropagation()}>
<div style={{ fontSize: 14, fontWeight: 600, marginBottom: 12 }}>选择窗口</div>
{windowList.map((w, i) => (
<button key={i} type="button" style={{ display: "block", width: "100%", textAlign: "left", padding: "8px 12px", border: "1px solid #ddd", borderRadius: 6, marginBottom: 6, cursor: "pointer", background: "#fafafa", fontSize: 13 }}
onClick={() => {
if (windowPickerField) onChange(windowPickerField, w.title);
onToast("已选择窗口:" + w.title);
setWindowPickerField(null);
}}>
<div style={{ fontWeight: 500 }}>{w.title || "(无标题)"}</div>
{w.process && <div style={{ fontSize: 11, color: "#888" }}>{w.process}</div>}
</button>
))}
</div>
</div>
)}
</>
); );
} }
@@ -873,8 +1182,6 @@ function RunTab({ node, component }: { node: FlowNode; component: Catalog["compo
<strong>未运行</strong> <strong>未运行</strong>
</div> </div>
<div> <div>
<span>执行器</span>
<strong>{component.implemented ? "已接入" : "待接入"}</strong>
</div> </div>
<div> <div>
<span>超时</span> <span>超时</span>
@@ -894,7 +1201,7 @@ function RunTab({ node, component }: { node: FlowNode; component: Catalog["compo
<Info className="icon" /> 说明 <Info className="icon" /> 说明
</div> </div>
<div className="field-hint" style={{ lineHeight: "17px" }}> <div className="field-hint" style={{ lineHeight: "17px" }}>
运行时会按顺序执行节点,记录状态、耗时、输出变量与截图证据。待接入节点会在此处显示失败原因与建议。 运行时会按顺序执行节点,记录状态、耗时、输出变量与截图证据。节点会在此处显示运行状态、失败原因与建议。
</div> </div>
</section> </section>
</> </>
+214 -198
View File
@@ -360,6 +360,13 @@ textarea:focus-visible {
color: var(--faint); color: var(--faint);
} }
/* 搜索容器自带聚焦光圈, 内层 input 不再叠加自己的 focus 框 */
.search-box input:focus-visible,
.task-search input:focus-visible {
outline: none;
box-shadow: none;
}
.seg { .seg {
display: grid; display: grid;
grid-auto-flow: column; grid-auto-flow: column;
@@ -538,19 +545,6 @@ textarea:focus-visible {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 3px rgba(7, 148, 85, 0.08);
}
.status-dot.pending {
background: #b9c2cd;
box-shadow: 0 0 0 3px rgba(120, 132, 148, 0.08);
}
.palette-more { .palette-more {
padding: 8px 14px 12px; padding: 8px 14px 12px;
display: flex; display: flex;
@@ -771,8 +765,6 @@ textarea:focus-visible {
.flow-canvas .react-flow { .flow-canvas .react-flow {
background-color: #f8fafc; background-color: #f8fafc;
background-image: radial-gradient(circle, rgba(138, 153, 172, 0.33) 1px, transparent 1px);
background-size: 20px 20px;
} }
.react-flow__node-rpa { .react-flow__node-rpa {
@@ -911,19 +903,6 @@ textarea:focus-visible {
white-space: nowrap; white-space: nowrap;
} }
.pending-badge {
margin-left: auto;
height: 16px;
padding: 0 5px;
border-radius: 5px;
background: #f0f3f7;
color: #8a96a6;
font-size: 9.5px;
font-weight: 650;
line-height: 16px;
white-space: nowrap;
}
.rpa-node-title { .rpa-node-title {
color: var(--ink); color: var(--ink);
font-size: 12.5px; font-size: 12.5px;
@@ -1203,7 +1182,13 @@ textarea:focus-visible {
} }
.field-label .required { .field-label .required {
padding: 1px 5px;
border-radius: 5px;
background: rgba(220, 38, 38, 0.08);
color: var(--danger); color: var(--danger);
font-size: 9px;
font-weight: 600;
line-height: 12px;
} }
.field-hint { .field-hint {
@@ -1212,6 +1197,14 @@ textarea:focus-visible {
line-height: 13px; line-height: 13px;
} }
/* 字段说明文案: 比一般 hint 更醒目易读 */
.field-desc {
margin-top: 3px;
color: var(--muted);
font-size: 10px;
line-height: 14px;
}
.text-input, .text-input,
.select-input, .select-input,
.textarea-input { .textarea-input {
@@ -2723,8 +2716,7 @@ textarea:focus-visible {
text-transform: uppercase; text-transform: uppercase;
} }
.page-heading-actions, .page-heading-actions {
.overview-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
@@ -2774,12 +2766,10 @@ textarea:focus-visible {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
display: grid; display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.75fr); grid-template-columns: minmax(0, 1fr);
gap: 12px;
} }
.task-list-panel, .task-list-panel {
.task-overview {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
display: flex; display: flex;
@@ -2839,7 +2829,7 @@ textarea:focus-visible {
.task-table-head, .task-table-head,
.task-row { .task-row {
display: grid; display: grid;
grid-template-columns: minmax(190px, 1.35fr) 90px minmax(100px, 0.8fr) minmax(100px, 0.85fr) 106px; grid-template-columns: minmax(190px, 1.35fr) minmax(100px, 0.8fr) minmax(110px, 0.85fr) minmax(100px, 0.75fr) 106px;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
} }
@@ -2880,10 +2870,7 @@ textarea:focus-visible {
.task-name-cell, .task-name-cell,
.task-trigger, .task-trigger,
.task-updated, .task-updated {
.task-status,
.overview-status,
.overview-meta dd {
display: flex; display: flex;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
@@ -2901,22 +2888,11 @@ textarea:focus-visible {
color: var(--blue-dark); color: var(--blue-dark);
} }
.task-icon.large {
width: 42px;
height: 42px;
border-radius: 11px;
}
.task-icon .icon { .task-icon .icon {
width: 16px; width: 16px;
height: 16px; height: 16px;
} }
.task-icon.large .icon {
width: 20px;
height: 20px;
}
.task-copy { .task-copy {
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -2951,56 +2927,18 @@ textarea:focus-visible {
flex: none; flex: none;
} }
.run-result.failed, .run-result.failed {
.overview-run.failed,
.overview-error {
color: var(--danger); color: var(--danger);
} }
.run-result.succeeded, .run-result.succeeded {
.overview-run.succeeded {
color: var(--success-deep); color: var(--success-deep);
} }
.run-result.cancelled, .run-result.cancelled {
.overview-run.cancelled {
color: var(--warn-deep); color: var(--warn-deep);
} }
.overview-error {
max-width: 210px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-status {
font-size: 10.5px;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 3px rgba(7, 148, 85, 0.08);
}
.task-status.paused .status-dot {
background: var(--faint);
box-shadow: 0 0 0 3px rgba(161, 172, 186, 0.12);
}
.task-status.paused {
color: var(--muted);
}
.task-status.enabled {
color: var(--success-deep);
}
.task-row-actions { .task-row-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -3054,108 +2992,6 @@ textarea:focus-visible {
line-height: 17px; line-height: 17px;
} }
.overview-empty {
flex: 1;
}
.task-overview {
padding: 20px;
}
.task-overview-head {
display: flex;
align-items: center;
gap: 11px;
}
.task-overview-head h2 {
max-width: 230px;
margin-top: 3px;
overflow: hidden;
color: var(--ink);
font-size: 17px;
line-height: 23px;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-status {
margin-top: 16px;
color: var(--success-deep);
font-size: 11px;
}
.overview-sep {
width: 1px;
height: 14px;
background: var(--line-strong);
}
.overview-actions {
margin-top: 16px;
}
.overview-actions .btn {
flex: 1;
justify-content: center;
}
.overview-meta {
margin-top: 20px;
display: grid;
gap: 0;
border-top: 1px solid var(--line);
}
.overview-meta > div {
min-height: 48px;
padding: 8px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--line);
}
.overview-meta dt {
color: var(--muted);
font-size: 11px;
}
.overview-meta dd {
justify-content: flex-end;
color: var(--ink-2);
font-size: 11px;
text-align: right;
}
.overview-meta dd .icon {
width: 13px;
height: 13px;
color: var(--blue);
}
.overview-note {
margin-top: auto;
padding: 10px;
display: flex;
align-items: flex-start;
gap: 8px;
border: 1px solid rgba(34, 100, 229, 0.1);
border-radius: 9px;
background: var(--blue-mist);
color: var(--ink-2);
font-size: 10.5px;
line-height: 16px;
}
.overview-note .icon {
width: 14px;
height: 14px;
flex: none;
color: var(--blue);
}
/* ---------- 组件树 ---------- */ /* ---------- 组件树 ---------- */
.palette-expand-all { .palette-expand-all {
min-height: 24px; min-height: 24px;
@@ -3351,13 +3187,9 @@ textarea:focus-visible {
} }
@media (max-width: 1250px) { @media (max-width: 1250px) {
.tasks-content {
grid-template-columns: minmax(0, 1fr) 300px;
}
.task-table-head, .task-table-head,
.task-row { .task-row {
grid-template-columns: minmax(170px, 1.3fr) 82px minmax(90px, 0.8fr) 96px 96px; grid-template-columns: minmax(170px, 1.3fr) minmax(90px, 0.8fr) 96px 96px 96px;
} }
} }
@@ -3444,3 +3276,187 @@ textarea:focus-visible {
.icon-btn.refresh-btn.refreshing .icon { .icon-btn.refresh-btn.refreshing .icon {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
.vision-lab {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
gap: 18px;
height: 100%;
min-height: 0;
padding: 18px;
}
.vision-lab-side {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
overflow: auto;
padding: 18px;
}
.vision-lab-side .panel-head {
align-items: center;
display: flex;
flex-direction: column;
gap: 4px;
}
.vision-lab-side .panel-head strong {
align-items: center;
display: flex;
font-size: 16px;
gap: 8px;
}
.vision-block {
display: flex;
flex-direction: column;
gap: 8px;
}
.vision-row {
align-items: center;
display: flex;
gap: 8px;
}
.vision-row select {
min-width: 0;
flex: 1;
}
.vision-row .btn {
flex: none;
}
.icon-btn {
padding-left: 10px;
padding-right: 10px;
}
.source-preview {
border: 1px solid #d8e0ea;
border-radius: 10px;
overflow: hidden;
}
.source-preview img {
display: block;
height: 116px;
object-fit: cover;
width: 100%;
}
.model-path {
border-radius: 8px;
background: #f3f6fa;
color: #64748b;
font-size: 12px;
line-height: 1.4;
max-height: 52px;
overflow: auto;
padding: 7px 9px;
word-break: break-all;
}
.vision-actions {
display: grid;
gap: 8px;
grid-template-columns: 1fr 1fr;
margin-top: auto;
}
.vision-error {
background: #fff1f1;
border: 1px solid #fecaca;
border-radius: 8px;
color: #b91c1c;
font-size: 12px;
line-height: 1.45;
padding: 9px 10px;
white-space: pre-wrap;
}
.vision-lab-stage {
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow: hidden;
padding: 18px;
}
.vision-stage-head {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 12px;
}
.vision-stage-head strong {
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vision-stage-head span {
color: #64748b;
flex: none;
font-size: 12px;
}
.vision-stage {
align-items: center;
background: #102033;
border-radius: 12px;
display: flex;
flex: 1;
justify-content: center;
min-height: 0;
overflow: hidden;
position: relative;
}
.vision-image {
display: block;
height: 100%;
object-fit: contain;
width: 100%;
}
.vision-box {
border: 2px solid #22d3ee;
box-shadow: 0 0 0 1px rgba(0, 0, 0, .35) inset;
position: absolute;
}
.vision-box span {
background: #0e7490;
color: white;
display: inline-block;
font-size: 11px;
line-height: 17px;
padding: 0 5px;
transform: translateY(-100%);
white-space: nowrap;
}
.vision-empty {
align-items: center;
color: #94a3b8;
display: flex;
height: 100%;
justify-content: center;
}
.vision-canvas {
display: block;
height: auto;
max-height: 100%;
max-width: 100%;
width: auto;
}
@@ -5,8 +5,8 @@ import { fieldIsActive, validateFlow } from "./flowValidation";
const catalog = { const catalog = {
schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [], schemaVersion: 1, generatedAt: "", counts: { total: 2, vendor: 1, native: 0, triggers: 1 }, groups: [],
components: [ components: [
{ id: "Trigger.Manual", name: "人工发起", kind: "trigger", group: "trigger", fields: [], slots: [], implemented: true }, { id: "Trigger.Manual", name: "人工发起", kind: "trigger", group: "trigger", fields: [], slots: [] },
{ id: "Test.Action", name: "测试动作", kind: "vendor", group: "data", fields: [{ key: "value", label: "内容", required: true, widget: "text", defaultValue: "" }], slots: [], implemented: false } { id: "Test.Action", name: "测试动作", kind: "vendor", group: "data", fields: [{ key: "value", label: "内容", required: true, widget: "text", defaultValue: "" }], slots: [] }
] ]
} as unknown as Catalog; } as unknown as Catalog;
@@ -38,10 +38,9 @@ describe("flow validation", () => {
expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true); expect(fieldIsActive("System_ScreenSaverShowV1", "password", { set_password: "1" })).toBe(true);
}); });
it("reports pending components, required fields, and disconnected nodes", () => { it("reports required fields and disconnected nodes", () => {
const issues = validateFlow(flow(), catalog); const issues = validateFlow(flow(), catalog);
expect(issues).toEqual(expect.arrayContaining([ expect(issues).toEqual(expect.arrayContaining([
expect.objectContaining({ code: "not_implemented", nodeId: "action" }),
expect.objectContaining({ code: "required", nodeId: "action" }), expect.objectContaining({ code: "required", nodeId: "action" }),
expect.objectContaining({ code: "disconnected", nodeId: "orphan", severity: "warning" }) expect.objectContaining({ code: "disconnected", nodeId: "orphan", severity: "warning" })
])); ]));
@@ -56,10 +55,10 @@ describe("flow validation", () => {
it("does not require inactive designated browser fields or optional screenshot output", () => { it("does not require inactive designated browser fields or optional screenshot output", () => {
const conditionalCatalog = structuredClone(catalog); const conditionalCatalog = structuredClone(catalog);
conditionalCatalog.components.push({ conditionalCatalog.components.push({
id: "Element.Input", name: "输入", kind: "vendor", group: "web", implemented: true, slots: [], id: "Element.Input", name: "输入", kind: "vendor", group: "web", slots: [],
fields: [{ key: "designated_obj", label: "指定对象", required: true, widget: "text", defaultValue: "" }] fields: [{ key: "designated_obj", label: "指定对象", required: true, widget: "text", defaultValue: "" }]
} as never, { } as never, {
id: "Image_Screenshot", name: "截图", kind: "vendor", group: "vision", implemented: true, slots: [], id: "Image_Screenshot", name: "截图", kind: "vendor", group: "vision", slots: [],
fields: [{ key: "image_path", label: "输出路径", required: true, widget: "file", defaultValue: "" }] fields: [{ key: "image_path", label: "输出路径", required: true, widget: "file", defaultValue: "" }]
} as never); } as never);
const definition = flow(); const definition = flow();
@@ -91,7 +90,7 @@ describe("flow validation", () => {
it("warns when a bare output variable name is used in an object field", () => { it("warns when a bare output variable name is used in an object field", () => {
const objectCatalog = structuredClone(catalog); const objectCatalog = structuredClone(catalog);
objectCatalog.components.push({ objectCatalog.components.push({
id: "Excel.Write", name: "写入", kind: "vendor", group: "office", implemented: true, slots: [], id: "Excel.Write", name: "写入", kind: "vendor", group: "office", slots: [],
fields: [ fields: [
{ key: "workbook", label: "工作簿对象", required: false, widget: "text", type: "object", defaultValue: "" }, { key: "workbook", label: "工作簿对象", required: false, widget: "text", type: "object", defaultValue: "" },
{ key: "value", label: "内容", required: true, widget: "text", defaultValue: "" } { key: "value", label: "内容", required: true, widget: "text", defaultValue: "" }
+24 -3
View File
@@ -29,6 +29,30 @@ export function fieldIsActive(componentId: string, key: string, values: Record<s
} }
} }
if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password); if (componentId === "System_ScreenSaverShowV1" && key === "password") return truthy(values.set_password);
// Image_Base64Encode(图片Base64编解码): path 仅编码方向, val/output 仅解码方向
if (componentId === "Image_Base64Encode") {
const b64mode = String(values.mode ?? "encode");
if (key === "path") return b64mode === "encode";
if (key === "val" || key === "output") return b64mode === "decode";
}
// Image_Screenshot: 区域坐标仅截图范围为「框选区域」时显示
if (componentId === "Image_Screenshot" && key === "position") return String(values.shotRange ?? "1") === "2";
// Window_*_v1 series: conditional fields based on get_type mode
if (/^Window_.*_v1$/.test(componentId) || componentId === "Window_WindowExists" || componentId === "Window_WaitWindow") {
const gt = String(values.get_type ?? "");
if (gt === "2") {
// get_type=2: match_type determines which title field is active
if (key === "match_type") return true;
const mt = String(values.match_type ?? "0");
if (key === "select_title") return mt === "0";
if (key === "match_title") return mt === "1";
}
if (gt !== "") {
if (key === "target_window") return gt === "0";
if (key === "window_obj") return gt === "1";
if (key === "match_type" || key === "select_title" || key === "match_title") return gt === "2";
}
}
return true; return true;
} }
@@ -54,9 +78,6 @@ export function validateFlow(flow: FlowDefinition, catalog: Catalog): FlowValida
issues.push({ severity: "error", code: "missing_component", nodeId: node.id, message: `组件不存在:${node.componentId}` }); issues.push({ severity: "error", code: "missing_component", nodeId: node.id, message: `组件不存在:${node.componentId}` });
continue; continue;
} }
if (!component.implemented) {
issues.push({ severity: "error", code: "not_implemented", nodeId: node.id, message: `「${node.label || component.name}」尚未接入执行器` });
}
for (const field of component.fields.filter((item) => item.required && item.widget !== "hidden" && item.widget !== "region")) { for (const field of component.fields.filter((item) => item.required && item.widget !== "hidden" && item.widget !== "region")) {
if (!requiredInContext(component.id, field.key, node.values)) continue; if (!requiredInContext(component.id, field.key, node.values)) continue;
const current = Object.prototype.hasOwnProperty.call(node.values, field.key) ? node.values[field.key] : field.defaultValue; const current = Object.prototype.hasOwnProperty.call(node.values, field.key) ? node.values[field.key] : field.defaultValue;
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { fieldIsActive } from "./flowValidation";
describe("Window component conditional fields", () => {
const componentId = "Window_CloseWindow_v1";
it("get_type=0 (拾取窗口): only target_window visible", () => {
const values = { get_type: "0" };
expect(fieldIsActive(componentId, "target_window", values)).toBe(true);
expect(fieldIsActive(componentId, "window_obj", values)).toBe(false);
expect(fieldIsActive(componentId, "match_type", values)).toBe(false);
expect(fieldIsActive(componentId, "select_title", values)).toBe(false);
expect(fieldIsActive(componentId, "match_title", values)).toBe(false);
expect(fieldIsActive(componentId, "isProcess", values)).toBe(true);
});
it("get_type=1 (选择窗口对象): only window_obj visible", () => {
const values = { get_type: "1" };
expect(fieldIsActive(componentId, "target_window", values)).toBe(false);
expect(fieldIsActive(componentId, "window_obj", values)).toBe(true);
expect(fieldIsActive(componentId, "match_type", values)).toBe(false);
expect(fieldIsActive(componentId, "select_title", values)).toBe(false);
expect(fieldIsActive(componentId, "match_title", values)).toBe(false);
});
it("get_type=2 + match_type=0: match_type + select_title visible", () => {
const values = { get_type: "2", match_type: "0" };
expect(fieldIsActive(componentId, "match_type", values)).toBe(true);
expect(fieldIsActive(componentId, "select_title", values)).toBe(true);
expect(fieldIsActive(componentId, "match_title", values)).toBe(false);
expect(fieldIsActive(componentId, "target_window", values)).toBe(false);
expect(fieldIsActive(componentId, "window_obj", values)).toBe(false);
});
it("get_type=2 + match_type=1: match_type + match_title visible", () => {
const values = { get_type: "2", match_type: "1" };
expect(fieldIsActive(componentId, "match_type", values)).toBe(true);
expect(fieldIsActive(componentId, "select_title", values)).toBe(false);
expect(fieldIsActive(componentId, "match_title", values)).toBe(true);
});
it("no get_type: all fields default visible", () => {
const values = {};
expect(fieldIsActive(componentId, "target_window", values)).toBe(true);
expect(fieldIsActive(componentId, "window_obj", values)).toBe(true);
expect(fieldIsActive(componentId, "match_type", values)).toBe(true);
});
it("Window_WindowExists also follows the pattern", () => {
expect(fieldIsActive("Window_WindowExists", "window_obj", { get_type: "1" })).toBe(true);
expect(fieldIsActive("Window_WindowExists", "target_window", { get_type: "1" })).toBe(false);
});
it("Window_WaitWindow also follows the pattern", () => {
expect(fieldIsActive("Window_WaitWindow", "match_title", { get_type: "2", match_type: "1" })).toBe(true);
expect(fieldIsActive("Window_WaitWindow", "select_title", { get_type: "2", match_type: "1" })).toBe(false);
});
it("non-window components unaffected", () => {
expect(fieldIsActive("System_ExecuteCommandV1", "dos_command", { command_type: "1" })).toBe(true);
expect(fieldIsActive("App_KillV1", "pid", { close_type: "id" })).toBe(true);
});
});
+43 -6
View File
@@ -59,7 +59,8 @@ export interface CatalogComponent {
output: CatalogOutput; output: CatalogOutput;
slots: string[]; slots: string[];
nodeKind?: string; nodeKind?: string;
implemented?: boolean; /** 同名旧版本 / 重复别名指向的保留能力 ID。 */
duplicateOf?: string;
} }
export interface CatalogGroup { export interface CatalogGroup {
@@ -184,6 +185,8 @@ export interface AiServiceConfig {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model: string; model: string;
/** 桌面 AI 拾取使用的视觉模型;留空时复用 model。 */
visionModel?: string;
} }
export interface AppSettings { export interface AppSettings {
@@ -191,10 +194,6 @@ export interface AppSettings {
aiConfig: AiServiceConfig; aiConfig: AiServiceConfig;
} }
export interface AppSettings {
browserElementWaitMs: number;
}
export interface ElementPickResult { export interface ElementPickResult {
selector: string; selector: string;
tag: string; tag: string;
@@ -235,7 +234,8 @@ export interface RunEvent {
| "node.started" | "node.started"
| "node.log" | "node.log"
| "node.finished" | "node.finished"
| "run.finished"; | "run.finished"
| "run.total";
runId: string; runId: string;
nodeId?: string; nodeId?: string;
level?: "info" | "warn" | "error"; level?: "info" | "warn" | "error";
@@ -247,5 +247,42 @@ export interface RunEvent {
evidence?: string[]; evidence?: string[];
attempt?: number; attempt?: number;
startedAt?: string; startedAt?: string;
nodeLabel?: string;
total?: number;
} }
export interface VisionLabSource {
id: string;
kind: "window" | "screen";
name: string;
thumbnail: string;
icon?: string;
}
export interface VisionLabBox {
x: number;
y: number;
width: number;
height: number;
category: string;
confidence: number;
}
export interface VisionLabDetectRequest {
sourceId: string;
modelPath?: string;
scoreThreshold?: number;
iouThreshold?: number;
}
export interface VisionLabDetection {
image: string;
width: number;
height: number;
boxes: VisionLabBox[];
captureMs: number;
detectMs: number;
modelPath: string;
sourceName: string;
}
+6
View File
@@ -0,0 +1,6 @@
declare module "sm-crypto" {
export const sm4: {
encrypt: (plainText: string, key: string, options?: { padding?: "pkcs#5" | "pkcs#7" | "none" }) => string;
decrypt: (cipherText: string, key: string, options?: { padding?: "pkcs#5" | "pkcs#7" | "none" }) => string;
};
}