Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fc1209c93 | ||
|
|
5b1b41663f | ||
|
|
8fdc62d98d | ||
|
|
4486c57536 | ||
|
|
04ac6130a0 | ||
|
|
8176bf7c75 | ||
|
|
8dc2b60c6d | ||
|
|
b4bcee8c15 | ||
|
|
9e9617256d | ||
|
|
6bf8e90c63 | ||
|
|
41b999115e | ||
|
|
4a3e6e38c7 | ||
|
|
b6829cbaab | ||
|
|
0d429e5175 | ||
|
|
38b2199d4b | ||
|
|
9bfa4ac107 | ||
|
|
bc4b349771 | ||
|
|
0651bc995c | ||
|
|
f163c8d0b6 | ||
|
|
fe0c6c2790 | ||
|
|
6b54decfb0 | ||
|
|
346e2ad342 | ||
|
|
d4f27cdaab | ||
|
|
6027895d4d | ||
|
|
12b97315f2 | ||
|
|
241c598b10 | ||
|
|
4824ac85f2 | ||
|
|
b5d6bef369 | ||
|
|
3922447689 | ||
|
|
e7669acd16 | ||
|
|
68e16282de | ||
|
|
9868f5d51c | ||
|
|
38af534f70 | ||
|
|
52423d46a4 | ||
|
|
0960be88e5 | ||
|
|
82765a7f19 | ||
|
|
817ea21c9a | ||
|
|
257fdc7358 | ||
|
|
5e6b7cd878 | ||
|
|
0641c5b91b | ||
|
|
b40c15d5b2 | ||
|
|
22054fed37 | ||
|
|
d98185d1cf | ||
|
|
5aa113ea0e | ||
|
|
c93543be1c | ||
|
|
58c23591a5 | ||
|
|
dfc5041a67 | ||
|
|
afa32133ec | ||
|
|
bf5ad56063 | ||
|
|
3bc8b60e83 | ||
|
|
f0b9d1beec | ||
|
|
f9b980ab5c | ||
|
|
22e3c191a8 | ||
|
|
32ac14a36f | ||
|
|
f8859fed77 | ||
|
|
541908961b | ||
|
|
03e3d31a83 | ||
|
|
bf70cf92e5 | ||
|
|
d7aff54bc6 | ||
|
|
56d00fba49 | ||
|
|
5795c39a6f | ||
|
|
1bb28e3368 | ||
|
|
29f3443110 | ||
|
|
88d5e61c5b | ||
|
|
ba17d6dc2a | ||
|
|
342145cc46 | ||
|
|
c11e1d1527 | ||
|
|
e5f64c21e2 | ||
|
|
1ddd1f1b35 | ||
|
|
b1fc8c25b8 | ||
|
|
1c85a90f1f | ||
|
|
9b9c6f17e4 | ||
|
|
d39c77324a | ||
|
|
927b18551f | ||
|
|
0132871885 | ||
|
|
d22468e29f | ||
|
|
e956ec9055 | ||
|
|
ee60435e2b | ||
|
|
510f0d041d | ||
|
|
95fb07e876 | ||
|
|
66169590de | ||
|
|
a1c65fb42f | ||
|
|
657bc6d7d0 | ||
|
|
ae4f4a2460 | ||
|
|
9a5f86c92a | ||
|
|
85dcb94056 | ||
|
|
e390b1b1eb | ||
|
|
84d73f1b86 | ||
|
|
23d292fdff | ||
|
|
32269a5760 | ||
|
|
adf5f3f7cf |
No files matched your search
@@ -12,3 +12,5 @@ test-output/
|
|||||||
web-capability-audit.txt
|
web-capability-audit.txt
|
||||||
|
|
||||||
*.traineddata
|
*.traineddata
|
||||||
|
|
||||||
|
temp/
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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`。
|
||||||
@@ -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 的唯一来源。
|
||||||
@@ -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 项覆盖区域解析/裁剪/九宫格
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+1972
-53
File diff suppressed because it is too large.
Load diff
@@ -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
+1183
-18
File diff suppressed because it is too large.
Load diff
+12930
-1204
File diff suppressed because it is too large.
Load diff
+165
-11
@@ -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.Show()
|
$hl.Bounds = $bounds
|
||||||
Set-ClickThrough $hl
|
$hl.Show()
|
||||||
|
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()
|
||||||
|
|||||||
+13618
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.
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,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,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。
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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 [
|
||||||
|
|||||||
@@ -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 | 名称 | 状态 |", "|---|---|---|---|"];
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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] : [];
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+201
-61
@@ -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,33 +291,46 @@ 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);
|
||||||
const testId = node.getAttribute('data-testid');
|
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||||
if (testId) {
|
}
|
||||||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
const testId = item.getAttribute('data-testid');
|
||||||
if (isUnique(candidate)) return candidate;
|
if (testId) {
|
||||||
}
|
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||||||
const parts = [];
|
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const parts = [];
|
||||||
|
let current = start;
|
||||||
|
while (current && current.nodeType === 1) {
|
||||||
|
const stableCandidate = stable(current);
|
||||||
|
if (stableCandidate) {
|
||||||
|
parts.unshift(stableCandidate);
|
||||||
|
return parts.join(' > ');
|
||||||
|
}
|
||||||
|
let part = current.tagName.toLowerCase();
|
||||||
|
const parent = current.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||||||
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
};
|
||||||
|
const trees = [];
|
||||||
let current = node;
|
let current = node;
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
let part = current.tagName.toLowerCase();
|
const root = current.getRootNode();
|
||||||
if (current.id) {
|
trees.unshift(localPath(current, root));
|
||||||
part += '#' + cssEscape(current.id);
|
current = root instanceof ShadowRoot ? root.host : null;
|
||||||
parts.unshift(part);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const parent = current.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
|
||||||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
|
||||||
}
|
|
||||||
parts.unshift(part);
|
|
||||||
current = parent;
|
|
||||||
}
|
}
|
||||||
return parts.join(' > ');
|
return trees.join(' >>> ');
|
||||||
};
|
};
|
||||||
const describe = (node) => ({
|
const describe = (node) => ({
|
||||||
kind: 'css',
|
kind: 'css',
|
||||||
@@ -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,33 +1182,46 @@ 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);
|
||||||
const testId = node.getAttribute('data-testid');
|
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||||
if (testId) {
|
}
|
||||||
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
const testId = item.getAttribute('data-testid');
|
||||||
if (isUnique(candidate)) return candidate;
|
if (testId) {
|
||||||
}
|
const candidate = '[data-testid="' + cssEscape(testId) + '"]';
|
||||||
const parts = [];
|
try { if (root.querySelectorAll(candidate).length === 1) return candidate; } catch {}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const parts = [];
|
||||||
|
let current = start;
|
||||||
|
while (current && current.nodeType === 1) {
|
||||||
|
const stableCandidate = stable(current);
|
||||||
|
if (stableCandidate) {
|
||||||
|
parts.unshift(stableCandidate);
|
||||||
|
return parts.join(' > ');
|
||||||
|
}
|
||||||
|
let part = current.tagName.toLowerCase();
|
||||||
|
const parent = current.parentElement;
|
||||||
|
if (parent) {
|
||||||
|
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
||||||
|
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
||||||
|
}
|
||||||
|
parts.unshift(part);
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
return parts.join(' > ');
|
||||||
|
};
|
||||||
|
const trees = [];
|
||||||
let current = node;
|
let current = node;
|
||||||
while (current && current.nodeType === 1) {
|
while (current && current.nodeType === 1) {
|
||||||
let part = current.tagName.toLowerCase();
|
const root = current.getRootNode();
|
||||||
if (current.id) {
|
trees.unshift(localPath(current, root));
|
||||||
part += '#' + cssEscape(current.id);
|
current = root instanceof ShadowRoot ? root.host : null;
|
||||||
parts.unshift(part);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const parent = current.parentElement;
|
|
||||||
if (parent) {
|
|
||||||
const siblings = [...parent.children].filter((item) => item.tagName === current.tagName);
|
|
||||||
if (siblings.length > 1) part += ':nth-of-type(' + (siblings.indexOf(current) + 1) + ')';
|
|
||||||
}
|
|
||||||
parts.unshift(part);
|
|
||||||
current = parent;
|
|
||||||
}
|
}
|
||||||
return parts.join(' > ');
|
return trees.join(' >>> ');
|
||||||
};
|
};
|
||||||
const describe = (node) => {
|
const describe = (node) => {
|
||||||
const rect = node.getBoundingClientRect();
|
const rect = node.getBoundingClientRect();
|
||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
method: "POST",
|
const maxAttempts = Math.max(1, Math.round(options.maxAttempts ?? (interactive ? 2 : 3)));
|
||||||
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
|
||||||
body: JSON.stringify({ model: config.model, messages, temperature: options.temperature ?? 0.3 })
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
});
|
let response: Response;
|
||||||
if (!response.ok) {
|
try {
|
||||||
|
response = await fetch(base + "/chat/completions", {
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: "Bearer " + config.apiKey },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: options.vision ? config.visionModel : config.model,
|
||||||
|
messages,
|
||||||
|
temperature: options.temperature ?? 0.3
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// 超时(AbortSignal.timeout)不重试:重发只会成倍拉长等待
|
||||||
|
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 } }> };
|
||||||
|
return data.choices?.[0]?.message?.content ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
const detail = (await response.text()).slice(0, 300);
|
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 请求失败(HTTP " + response.status + "):" + detail);
|
||||||
}
|
}
|
||||||
const data = (await response.json()) as { choices?: Array<{ message?: { content?: string } }> };
|
|
||||||
return data.choices?.[0]?.message?.content ?? "";
|
throw new RuntimeError("ai_request_failed", "AI 请求失败:已重试仍未成功");
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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 }
|
||||||
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
|
if ($winName) {
|
||||||
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
$cond = New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::NameProperty, $winName)
|
||||||
if ($win) { return $win }
|
|
||||||
Add-RpaUser32
|
|
||||||
$script:hitHwnd = [IntPtr]::Zero
|
|
||||||
$cb = [WinRpa.RpaUser32Click+EnumProc]{ param($h, $l)
|
|
||||||
$sb = New-Object System.Text.StringBuilder 256
|
|
||||||
[WinRpa.RpaUser32Click]::GetWindowTextW($h, $sb, 256) | Out-Null
|
|
||||||
if ($sb.ToString() -eq $winName) { $script:hitHwnd = $h; return $false }
|
|
||||||
return $true
|
|
||||||
}
|
|
||||||
[WinRpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero) | Out-Null
|
|
||||||
if ($script:hitHwnd -ne [IntPtr]::Zero) {
|
|
||||||
[WinRpa.RpaUser32Click]::ShowWindow($script:hitHwnd, 9) | Out-Null
|
|
||||||
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)
|
$win = $root.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
||||||
if ($win) { return $win }
|
if ($win) { return $win }
|
||||||
}
|
}
|
||||||
throw "window_not_found:$winName"
|
Add-RpaUser32
|
||||||
|
$script:hitHwnd = [IntPtr]::Zero
|
||||||
|
$cb = [Win32Rpa.RpaUser32Click+EnumProc]{ param($h, $l)
|
||||||
|
if (-not [Win32Rpa.RpaUser32Click]::IsWindowVisible($h)) { return $true }
|
||||||
|
$titleSb = New-Object System.Text.StringBuilder 512
|
||||||
|
[void][Win32Rpa.RpaUser32Click]::GetWindowTextW($h, $titleSb, 512)
|
||||||
|
$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 }
|
||||||
|
}
|
||||||
|
$script:hitHwnd = $h
|
||||||
|
return $false
|
||||||
|
}
|
||||||
|
[void][Win32Rpa.RpaUser32Click]::EnumWindows($cb, [IntPtr]::Zero)
|
||||||
|
if ($script:hitHwnd -ne [IntPtr]::Zero) {
|
||||||
|
try { $win = [System.Windows.Automation.AutomationElement]::FromHandle($script:hitHwnd); if ($win) { return $win } } catch {}
|
||||||
|
}
|
||||||
|
$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);
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
});
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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 };
|
||||||
});
|
});
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -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];
|
||||||
|
});
|
||||||
|
});
|
||||||
+68
-15
@@ -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();
|
||||||
...component,
|
const duplicates = duplicateMap || loadDuplicateMap();
|
||||||
implemented: RUNTIME_CONTAINERS.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName)
|
duplicateMap = duplicates;
|
||||||
}));
|
catalog.components = catalog.components
|
||||||
|
.filter((component) => !excluded.has(component.id))
|
||||||
|
.map((component: CatalogComponent) => ({
|
||||||
|
...component,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+919
-54
File diff suppressed because it is too large.
Load diff
@@ -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
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 } },
|
||||||
|
|||||||
+38
-7
@@ -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();
|
||||||
if (!hasBrowserSession() && !url) {
|
const browserOptions = options.browser;
|
||||||
throw new Error("请先配置或运行「打开网页」,再拾取元素");
|
let session;
|
||||||
|
if (browserOptions) {
|
||||||
|
session = await externalBrowserSession(
|
||||||
|
browserOptions.mode === "launch" && url ? { ...browserOptions, initialUrl: url } : browserOptions
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session = latestExternalSession();
|
||||||
|
}
|
||||||
|
if (!session) {
|
||||||
|
if (!hasBrowserSession() && !url) {
|
||||||
|
throw new Error("请先配置或运行「打开网页」,再拾取元素");
|
||||||
|
}
|
||||||
|
session = await BrowserSession.create();
|
||||||
}
|
}
|
||||||
const 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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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
@@ -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());
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -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: "" } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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 ?? "")
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" ? "屏幕" : "未命名窗口")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 || {})
|
||||||
|
|||||||
@@ -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() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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/<runId>/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</p></SettingCard>
|
<SettingCard icon={HardDrive} title="数据与证据"><p className="field-hint setting-copy">流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/<runId>/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
onChange(field.key, region.imagePath);
|
// 只有「搜索区域」类字段(displayType=region, 如 CV_* 的 searchRegion)存区域坐标 JSON;
|
||||||
onToast("已框选并截图:" + region.width + "×" + region.height);
|
// 「屏幕截图」类字段(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);
|
||||||
|
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]) {
|
catch (error) {
|
||||||
onChange(field.key, list[idx].title);
|
|
||||||
onToast("已选择窗口:" + list[idx].title);
|
|
||||||
}
|
|
||||||
} 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,47 +747,79 @@ 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 }} />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
{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>
|
</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>
|
||||||
|
)}
|
||||||
</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.widget === "secret" ? (
|
) : 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" ? (
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
<input
|
<input
|
||||||
className="text-input"
|
className="text-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
@@ -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: "" }
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
Vendored
+6
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in new issue
Block a user