feat: 画布式 RPA 桌面端 Day1(1016 目录/画布/容器/属性/持久化)

This commit is contained in:
yu 2026-10-26 14:38:17 +08:00
commit 32ca0a1af8
34 changed files with 15141 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules/
out/
release/
_analysis/
etest2/
*.log
*.tmp
.DS_Store
Thumbs.db

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# RPA Studio
画布式 RPA 桌面端(Electron + React Flow)。把 1016 项能力目录拖到画布上,连线、配置、运行,生成可执行的 RPA 流程。
> 当前状态:Day 1 纵向切片。画布、1016 项目录、属性面板、容器(IF/循环)、保存/加载已可用;执行内核与运行时(浏览器/桌面/Office)按三天计划在 Day 2–3 接入。
## 已实现(Day 1)
- Electron 31 + React 18 + TypeScript + React Flow 画布
- 1016 项组件目录:997 厂商 + 16 控制 + 3 触发器,搜索 / 11 组筛选 / 可执行与待接入状态
- 拖拽到画布、节点卡片、连线、缩放/平移/MiniMap、框选、撤销/重做
- IF / 计次循环 / 遍历 / 条件循环 / 异常捕获 容器:可把组件拖入分支或循环体
- 属性面板:按字段类型渲染输入、必填、变量、失败策略(超时/重试/间隔/跳过/延时)
- 流程 JSON 本地原子保存、自动保存、重启恢复
- 运行页与设置页骨架;运行期完全离线
## 开发
```powershell
npm install
npm run dev:local # 使用 RPA_ELECTRON_DIST 或 C:\electron-rpa\dist 的 Electron
# 或
npm run build
node scripts/start.mjs --no-sandbox
```
> 为什么需要 `dev:local`:当前工作区 `D:\RPA` 的 ACL 会让 Electron 自带的 Chromium 沙箱启动失败(`--version` 都返回 0x80000003)。
> 把 Electron 运行时放在工作区外(例如 `C:\electron-rpa\dist`)即可正常启动;打包产物解压到普通目录后不受影响。
> `--no-sandbox` 仅用于本地调试,不作为产品默认。
## 目录
```
src/main 主进程:窗口、目录加载、流程存储、IPC
src/preload 白名单桥
src/renderer React UI:画布、组件库、属性面板、运行/设置页
src/shared 主/渲染共享类型
scripts build-catalog / dev-safe / start / cdp-probe / 打包
resources catalog.json(1016 项派生元数据)、夹具站、桌面桥
docs 现状分析、实施方案
```
## 文档
- [现状分析与可行性](docs/01-现状分析与可行性.md)
- [实施方案(三天)](docs/02-实施方案.md)
## 边界
- 1016 项目录中当前只有 15 项标记为可执行(控制/触发/基础);其余灰标「待接入」,运行到该节点会明确失败。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。

View File

@ -0,0 +1,334 @@
# 画布式 RPA 桌面端 · 现状分析与可行性(调研稿)
> 编写时间:2026-10-26
> 参考目录:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)
> 本文件是决策输入,不是最终实施方案;带「待确认」的条目需要产品方拍板。
---
## 0. 一句话结论
`D:\code\Agent-Employee` 里的 **1016 项 RPA 目录全部是静态元数据**(997 个厂商组件定义 + 16 个原生控制节点 + 3 个项目自有触发器),**不含任何厂商运行时**;`C:\rpa-demo` 只有**浏览器**动作层(CDP 可信输入,26/26 自测通过),没有画布、没有桌面能力、没有执行引擎。
因此三天内**不可能**做出"1016 个能力都能跑"的产品。可行的目标是:
> **一个本地优先的画布式 RPA 桌面端纵向切片**:左侧 1016 项目录可搜索、可拖拽、可配置;画布支持连线/分支/循环/变量;点运行后由真实执行内核跑通一条端到端流程;未接入的组件在目录和节点上显式标记,运行时给出明确失败原因。
建议真实可执行组件目标 **100~140 项**(覆盖浏览器、文件、Excel、字符串/时间/列表、键鼠/窗口、HTTP/邮件),其余标记「目录可用、执行器待接入」。
---
## 1. 参考资产盘点
### 1.1 `D:\code\Agent-Employee`:产品全量仓库,RPA 只是其中一块
| 资产 | 位置 | 结论 |
|---|---|---|
| 1016 项目录 | `code/frontend/apps/desktop/src/renderer/product-preview/pages/workflows/shizai-catalog-data.js`(1.8 MB) | **可直接复用**:997 组件完整字段、默认值、分类、函数签名 |
| 目录投影/分组 | 同目录 `component-catalog.js` | 可复用 11 组分组规则、字段类型映射、3 个触发器、别名表 |
| 现有流程页 | `product-preview/pages/workflows/page.js`(50 KB) | 已有:搜索筛选、增删节点、属性面板、缩放、纵向流式画布、localStorage 草稿。**不足:不是自由画布,不能拖拽定位,没有执行器** |
| 设计原型 | `项目资料/UI设计/20261021/流程编排页原型.html` | 纵向线性流程,非拖拽画布;可作视觉基调参考 |
| 浏览器插件契约 | `code/backend/packages/browser-plugin/src/index.ts` | 只声明 `navigate/click/type/snapshot` 四个动作,`unsigned-development` |
| Windows 插件契约 | `code/backend/packages/windows-plugin/src/index.ts` | 只声明契约,无实现 |
| Windows Runner | `code/backend/services/windows-runner/native/*.cs` | **只读 UIA/截图观察**,注释明确"没有 SendInput / SetForegroundWindow / 进程启动" |
| 工作流引擎 | `code/backend/packages/workflow-engine/src/*` | 服务端 PostgreSQL 工作流运行/审批/恢复,**不适合本机三天产品**(要 Docker/PG/多服务) |
| 预览客户端 | `交付物/客户端/Windows/x64/0.1.0-preview.1/` | 已有 Electron 安装包,但只跑前端演示,不含 RPA 执行器 |
**判断**:目录元数据是最大可复用资产;现有画布是"线性步骤列表",与用户要的"自由拖拽画布"不是一回事;后端工作流栈不适合三天工期。
### 1.2 1016 项目录的真实构成
来自 `shizai-catalog-data.js` 的 `counts`(已用脚本解析核对):
| 项 | 数量 |
|---|---:|
| 厂商组件定义 | 997 |
| 原生控制节点(IF/循环/异常/子流程…) | 16 |
| 项目自有触发器(人工/定时/文件到达) | 3 |
| **合计** | **1016** |
| 普通输入字段 | 4258(其中必填 1318,占 31%) |
| 输出字段 | 690 |
| 选项字段 | 6148 |
| 唯一函数名 | 997(组件与函数 1:1) |
| 字段数为 0 的组件 | 22 |
| 字段数 1–3 / 4–8 / 9–15 / 16+ | 473 / 414 / 75 / 13 |
按产品分组(沿用现有 `component-catalog.js` 规则):
| 分组 | 组件数 | 说明 |
|---|---:|---|
| 网页自动化(WebBrowser + Element) | 187 | 浏览器与元素操作,最接近 rpa-demo 能力 |
| Office 与文档(Excel/Word/PDF/转换) | 230 | Excel 单独 170 项,是最大单一包 |
| 数据处理(表格/字符串/时间/列表/字典/数学/正则…) | 167 | 纯函数为主,最容易批量实现 |
| 图像与 OCR | 100 | 依赖视觉模型/厂商服务,三天不做 |
| 网络与消息(HTTP/邮件/通知/NLP) | 82 | HTTP/邮件可做,NLP 不做 |
| 桌面与键鼠(键盘/鼠标/窗口/剪贴板/对话框) | 79 | 需 Windows 原生桥 |
| 应用与系统(App/System/Basic/屏幕控制) | 60 | 部分可做(启动进程/环境变量/延迟/变量) |
| 文件与传输(File/FTP) | 46 | 可做文件系统,FTP 可选 |
| 移动与外设 | 46 | 需 ADB/Appium/机械臂,三天不做 |
| 编排控制 | 16 | IF/计次/条件/无限循环/遍历/异常/子流程/退出 |
| 触发器 | 3 | 人工/定时/文件到达 |
**关键事实**:`functions[].function`(如 `Excel.Cs_ReadContentV2`)只是**名称映射**,原厂商实现依赖 Python 包、原生 SDK、浏览器扩展、Office COM、云端 OCR 等,目录里没有一行可执行代码。仓库 README 也明确写着"不代表这些动作已经可执行"。
### 1.3 `C:\rpa-demo`:可复用的浏览器执行层
| 能力 | 事实 |
|---|---|
| 形态 | Electron 31.7.7 + 多标签 `WebContentsView` + 右侧任务面板 |
| 动作层 | `actions.js`:CDP `Input` 域可信输入,5 级定位(testid→aria→text→css→xpath),统一结果/错误码/后置条件/证据 |
| 流程 | `flows/*.json` 声明式步骤 + 顺序回放 + 失败即停 + 每步证据 |
| 拾取器 | 注入点击捕获,生成候选 locator |
| 安全 | 域名 allowlist、高影响动作开关、`actionId` 幂等去重 |
| 自测 | `selftest-out.txt`:**26/26 通过**(点击/输入/下拉/等待/表格/错误分类/证据/越界/流程回放) |
| 不足 | 只有浏览器;无画布;无变量系统(只有 runId 注入);无桌面/Office;单窗口任务面板是死脚本 |
**判断**:`actions.js` + 拾取器 + 流程回放语义可以整体搬进新产品,改造成本低,是三天内"真的能跑"的最大保障。
### 1.4 环境事实(本机)
| 项 | 结果 |
|---|---|
| OS | Windows 11 专业版 26200 |
| Node / npm / Bun | 22.13.0 / 10.9.2 / 1.4.3 |
| Python | `py -3.10` 可用;已装 numpy/Pillow/opencv/pyautogui/pywin32;**无** pandas/openpyxl/python-docx/PyMuPDF |
| Office COM | Excel、Word 已安装(`Excel.Application` / `Word.Application` 注册存在) |
| 显示 | 1920×1080,主屏 100% 缩放(125%/150% 仍需冒烟) |
| 网络 | npm 官方源与 npmmirror 均可达 |
| 磁盘 | C: 仅剩 ~9.9 GB;D: 剩 ~159 GB → **项目与依赖放 D:** |
| Electron | `C:\rpa-demo\node_modules\electron` 已下载 31.7.7,可离线复用 |
---
## 2. 能做 / 不能做(三天口径)
### 2.1 建议纳入"真实可执行"
| 层 | 能力 | 预计可执行组件 | 实现方式 |
|---|---|---|---|
| L0 控制 | IF、计次循环、遍历列表、条件循环、异常捕获、跳出/继续、退出、子流程、设置变量、延迟、日志 | 16 个控制节点 + 基础组件 | 自研解释器 |
| L1 浏览器 | 打开/导航/点击/输入/取文本/取属性/等待/存在判断/下拉/悬停/滚动/截图/表格提取/执行 JS/标签页/上传下载 | 60~80 | 复用 `actions.js` + CDP + 拾取器 |
| L2 文件 | 读/写/复制/移动/删除/重命名/存在/列表/建目录/压缩/解压 | 25~35 | Node `fs` + `adm-zip` |
| L3 Excel/PDF/Word | 新建/打开/读区域/写单元格/写区域/追加/保存/工作表增删改/查找;PDF 页数/文本/合并;Word 写入 | 35~45 | `exceljs` + `pdf-lib` + `pdfjs-dist`;Word 走 COM 桥 |
| L4 数据 | 字符串/时间/数学/正则/列表/字典/JSON/CSV/数据表筛选排序去重 | 50~70 | 纯 JS 函数库 |
| L5 桌面 | 键鼠输入/热键/坐标点击/窗口查找激活/窗口状态/进程启动/剪贴板/消息框/文件对话框/环境变量/命令执行 | 35~45 | PowerShell 5.1 桥(UIA + SendInput) |
| L6 网络 | HTTP 请求、SMTP 发信、FTP 上传下载(可选)、PostgreSQL(可选) | 8~15 | `fetch` / `nodemailer` / `basic-ftp` / `pg` |
**合计目标:约 110~150 项可执行**(按函数名注册,允许同类组件共享实现内核)。
### 2.2 明确不做(三天)
- 移动端/外设/机械臂(Mobile、peripherals,46 项)
- OCR、验证码、图像模板匹配、视觉定位(100 项;最多做"截图 + 区域保存")
- NLP/AIGC/云服务类(NLP、DataService、Scenario 等)
- SAP、IE、Java 应用、Chrome 扩展等厂商专有通道
- 并行分支、分布式执行、审批流、多租户、云端市场
- 厂商 Python 运行时对接(本机未安装该产品,且无授权运行时)
### 2.3 "未接入"组件的产品处理
建议(待确认):**目录全量保留 + 状态徽标**。
- 可执行:绿色圆点 + 可拖拽
- 待接入:灰色徽标「待接入」,可拖拽但节点置灰,运行时在日志中明确报 `capability_not_implemented`,不静默跳过
- 目录顶部显示统计:`1016 项目录 / 当前可执行 N 项`
- 属性面板仍可编辑(元数据完整),便于后续接执行器
这样既满足"1016 个能力都能拖"的观感,又不谎称可执行。
---
## 3. 推荐技术方案
### 3.1 形态
**新建独立 Electron 桌面应用(`D:\RPA`),不直接改 `Agent-Employee`。**
理由:
1. `Agent-Employee` 是 OpenCode v2 fork + 服务端栈,启动/构建/依赖都重;三天内集成会先耗在环境上。
2. 本产品需要主进程直接持有 `WebContentsView` 做 CDP、直接 `child_process` 调 PowerShell,独立应用最直接。
3. 目录元数据、画布视觉、动作语义都可复制/对齐,后续可平移回 `Agent-Employee`。
4. 交付物清晰:一个可双击的 exe + 源码 + 文档。
### 3.2 技术栈
| 层 | 选型 | 说明 |
|---|---|---|
| 壳 | Electron 31.7.7 | 复用本机已缓存版本,避免大版本差异 |
| 构建 | electron-vite + Vite + React 18 + TypeScript | 主进程/预加载/渲染进程一套工程 |
| 画布 | `@xyflow/react`(React Flow) | 拖拽、连线、缩放、MiniMap、分组容器,三天内唯一现实选择 |
| 状态 | Zustand + immer | 画布状态、运行状态分离 |
| 样式 | 原生 CSS 变量 + 设计令牌 | 对齐现有预览视觉(浅色、蓝灰、圆角) |
| 持久化 | 本地 JSON 文件(`userData/flows/*.json`)+ 运行记录 JSONL | 不引入数据库 |
| 执行 | 主进程 Node 解释器 + 能力注册表 | 与 UI 通过 IPC 事件流通信 |
| 桌面 | PowerShell 5.1 常驻子进程(JSON over stdio) | UIAutomation + user32 `SendInput`,免原生模块编译 |
| 浏览器 | 内嵌 `WebContentsView` + CDP | 搬 `actions.js` |
| 打包 | `@electron/packager` 产出 portable 目录/ZIP;有余量再加 NSIS | 避开 electron-builder 的签名工具下载风险 |
### 3.3 画布数据模型(草案)
```jsonc
{
"schemaVersion": 1,
"id": "flow_01",
"name": "网页订单录入",
"variables": [{ "name": "orderNo", "type": "string", "default": "" }],
"nodes": [
{ "id": "n1", "componentId": "Trigger.Manual", "position": { "x": 80, "y": 40 }, "values": {} },
{ "id": "n2", "componentId": "WebBrowser_CreateV3", "position": { "x": 80, "y": 180 },
"values": { "browserType": "内置浏览器" }, "outputVar": "web" },
{ "id": "n3", "componentId": "logic_control_if_else", "position": { "x": 80, "y": 320 },
"values": { "condition": "${amount} > 1000" } },
{ "id": "n4", "componentId": "Element_Click_V6", "position": { "x": 80, "y": 420 },
"parentId": "n3", "slot": "statement_if", "values": { "uiSelector": "…" } }
],
"edges": [{ "id": "e1", "source": "n1", "target": "n2", "sourceHandle": "out" }]
}
```
- **节点**:`componentId` 指向 1016 目录;`values` 是字段键值;`outputVar` 接收返回值。
- **容器**:IF/循环/异常节点通过 `parentId` + `slot` 形成子画布;React Flow 原生父子节点即可承载。
- **连线**:默认顺序边;IF 节点两个出口 `true/false`;循环体走容器子节点,循环后走 `after` 出口。
- **变量**:`${var}`、`${var.field}`、`${list[0]}`;表达式使用自研小型解析器(不用 `eval`)。
### 3.4 执行模型
```
校验(组件存在/必填/连线/环检测)
→ 编译(容器树 → 执行计划)
→ 逐节点执行(上下文:变量、密钥引用、浏览器会话、证据目录)
→ 每个节点产出 { status, output, logs, evidence, errorCode, durationMs }
→ 失败按节点策略(停止 / 重试 N 次 / 跳过 / 人工确认)
```
错误码封闭集合:`not_found / timeout / blocked_url / capability_not_implemented / expression_error / io_error / office_error / desktop_error / cancelled / unknown`。
**证据**:每步可截图(浏览器/桌面)、保存输出摘要;运行目录 `userData/runs/<runId>/`,路径只回传相对路径。
### 3.5 目录 → 表单 → 执行器
1. 启动时把 1016 项加载为 `ComponentDefinition`(离线 JSON,随包)。
2. 字段 `displayType`(200+ 种)归并为 12 类控件:文本/表达式、数字、下拉、单选、复选、文件、目录、多行、代码、密钥、元素选择器、区域选择;未知类型降级为文本并标注。
3. 执行器注册表以 `functions[].function` 为键:
```ts
register("StrUtil.SplitStr", (ctx, args) => { … })
register("Excel.Cs_ReadContentV2", async (ctx, args) => { … })
```
4. 未注册的函数在编译期标记 `implemented=false`,运行时返回 `capability_not_implemented`,并给出"已注册 N/997"的统计。
### 3.6 桌面桥(关键风险点)
PowerShell 5.1 常驻进程,stdin/stdout 行分隔 JSON:
| 命令 | 实现 |
|---|---|
| `win.find/activate/state/rect/close` | user32 `FindWindow/EnumWindows/SetForegroundWindow/ShowWindow` |
| `uia.find/invoke/setValue/getText` | `UIAutomationClient` 程序集 |
| `input.click/move/scroll/type/hotkey` | user32 `SendInput`(绝对坐标用 `MOUSEEVENTF_ABSOLUTE`) |
| `screen.capture` | `System.Drawing` 截屏 |
| `clip.get/set` | `System.Windows.Forms.Clipboard` |
要点:
- 启动时声明 Per-Monitor DPI Awareness,坐标统一用**物理像素**;Electron 侧按 `screen.getPrimaryDisplay().scaleFactor` 换算。
- UIA 操作优先(元素可访问),坐标点击作为兜底并标注风险。
- 目标窗口不在前台时先 `SetForegroundWindow`,失败要显式报错,不盲点。
### 3.7 打包与交付
- `npm run build` → `dist/win-unpacked/`,双击 `RPA Studio.exe` 启动。
- `npm run package` → ZIP + 版本清单 + SHA256。
- 交付:源码、可执行包、`README`、示例流程 2 条、自测报告、演示脚本。
---
## 4. 三天排期(含今天 2026-10-26)
> 假设:单人 + AI 辅助,今天可用约 12 小时,后两天各约 12 小时。每半天一个可验收产物。
### Day 1(10/26):地基 + 画布
| 时段 | 任务 | 验收 |
|---|---|---|
| 上午 | 工程脚手架(Electron+Vite+React+TS)、设计令牌、三栏布局、路由 | `npm run dev` 起窗口 |
| 下午 | 目录加载与 11 组分组、搜索/虚拟列表、拖拽入画布、节点卡片、连线/删除/框选、缩放/MiniMap | 能从 1016 项拖 5 个节点并连线 |
| 晚上 | 属性面板(12 类控件)、变量面板、流程保存/加载、撤销重做 | 刷新后流程还原 |
| 睡前 | 容器节点(IF/循环)父子拖放与几何 | 能把节点拖进 IF 分支 |
### Day 2(10/27):执行内核 + 浏览器 + 桌面
| 时段 | 任务 | 验收 |
|---|---|---|
| 上午 | 解释器(编译/执行/变量/表达式/错误策略/日志事件) | 直线流程可跑 |
| 下午 | 浏览器运行时(搬 `actions.js`)+ 元素拾取器 + 截图证据 | 打开网页、点击、输入、取文本 |
| 晚上 | 桌面桥(PowerShell)+ 键鼠/窗口/剪贴板/对话框 | 能操作记事本/计算器 |
| 睡前 | 数据/文件/字符串/时间 批量执行器(60+) | 单节点用例通过 |
### Day 3(10/28):Office/网络 + 联调 + 打包
| 时段 | 任务 | 验收 |
|---|---|---|
| 上午 | Excel/PDF/Word、HTTP/邮件执行器 | 读写 xlsx、发测试邮件 |
| 下午 | 运行面板(状态高亮/日志/变量/证据)、停止/重试、示例流程 | 端到端流程跑通 |
| 晚上 | 打包 portable、冒烟(100%/125%/150%)、README/演示脚本/自测报告 | 双击 exe 可演示 |
**若进度落后,砍功能顺序**:FTP/DB → Word/PDF → 邮件 → 桌面 UIA(保留坐标键鼠)→ 循环容器(保留 IF)→ 目录全量(退化为可执行子集)。
---
## 5. 建议的验收脚本(Demo)
**场景 A(主推,跨 Web + Office + 消息)**
1. 打开内置浏览器 → 访问本地夹具页/演示站
2. 填写表单(姓名/金额)→ 点击提交
3. 抓取结果表格 → 写入 Excel(新建/指定模板)
4. IF 金额 > 1000 → 发邮件/桌面通知;否则记录日志
5. 运行面板显示每步耗时、截图、变量值
**场景 B(纯桌面 + 文件)**
1. 启动记事本 → 输入文本 → 保存到指定目录
2. 读取目录内 xlsx → 筛选 → 另存为新文件
3. 弹窗提示完成
**画布验收**:1016 项可搜、11 组筛选、拖拽/连线/容器/变量/保存重载/运行高亮/失败定位。
---
## 6. 风险清单
| 风险 | 影响 | 缓解 |
|---|---|---|
| "1016 个组件"被理解成"1016 个都能跑" | 验收预期落差 | 目录徽标 + 启动页统计 + 运行时明确报错;文档写清边界 |
| 三天做不完 | 交付缩水 | 纵向切片优先;未完成项按"砍功能顺序"降级 |
| 桌面自动化 DPI/焦点/权限 | 演示失败 | PowerShell 桥显式 DPI、UIA 优先、坐标兜底;100/125/150 冒烟 |
| Excel COM 慢/弹窗/版本差异 | 卡住流程 | 文件级用 `exceljs`;COM 只用于"可见打开"演示 |
| 内嵌浏览器与用户 Chrome 差异 | 登录态/站点兼容 | 明确"内置浏览器",不做外部 Chrome 接管 |
| 未接入组件被拖入流程 | 运行中断 | 编译期阻断 + 画布红标 + 一键定位 |
| C 盘空间不足 | 构建失败 | 全部依赖与缓存放 D 盘;Electron 复用本地缓存 |
| 第三方包下载失败 | 阻塞 | 已确认 npm 源可达;关键包锁定版本并本地缓存 |
---
## 7. 待产品方确认(详见对话提问)
1. 基底:独立新项目 vs 并入 `Agent-Employee`
2. 三天验收重心:纵向切片 / 组件广度 / 画布完成度
3. 必须真实执行的类别
4. 未接入组件的处理方式
5. 画布控制结构深度(IF / 循环 / 并行)
6. 演示场景(网页+Excel+邮件 / 纯桌面 / 自定义系统)
7. 交付物形态(portable exe / 安装包 / 源码 / 视频)
8. 环境约束(可装 npm 包、可调 PowerShell、是否离线、缩放适配)
---
## 8. 参考文件索引
- 目录数据:`D:\code\Agent-Employee\code\frontend\apps\desktop\src\renderer\product-preview\pages\workflows\shizai-catalog-data.js`
- 目录投影:同目录 `component-catalog.js`
- 现有流程页:同目录 `page.js`、`index.html`
- 设计原型:`D:\code\Agent-Employee\项目资料\UI设计\20261021\流程编排页原型.html`
- 浏览器动作层:`C:\rpa-demo\actions.js`、`main.js`、`flows.js`、`selftest.js`
- 动作契约:`C:\rpa-demo\AI-接手开发指南.md`
- 浏览器插件契约:`D:\code\Agent-Employee\code\backend\packages\browser-plugin\src\index.ts`
- Windows 只读 Runner:`D:\code\Agent-Employee\code\backend\services\windows-runner\native\NativeWindows.cs`
- 厂商实现分析:`D:\code\Agent-Employee\项目资料\开发方案\实在ai分析结果\01-总览与结论\全功能实现逻辑总览.md`

657
docs/02-实施方案.md Normal file
View File

@ -0,0 +1,657 @@
# 画布式 RPA 桌面端 · 实施方案(三天)
> 制定时间:2026-10-26 09:50
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
---
## 0. 决策记录(2026-10-26 确认)
| # | 问题 | 结论 |
|---|---|---|
| 1 | 代码基底 | 在 `D:\RPA` 新建独立 Electron 应用 |
| 2 | 三天验收重心 | 一条真实流程端到端跑通 |
| 3 | 必须真实执行 | 浏览器网页、文件与 Excel、桌面键鼠/窗口/剪贴板/对话框、数据/字符串/时间/列表/正则、HTTP 与邮件、PDF/Word;图像/OCR「最好有」;移动端不做;数据库/FTP 靠后 |
| 4 | 未接入组件 | **全量 1016 可拖拽;未接入项灰标「待接入」,运行时明确报错**(2026-10-26 10:20 调整,覆盖早前"只导入可执行子集") |
| 5 | 画布深度 | 顺序 + IF + 循环容器 |
| 6 | 演示场景 | 待定;默认用本地夹具页,可替换为你的系统 |
| 7 | 元素拾取 | 浏览器必须;桌面手工填 |
| 8 | 交付物 | 可双击运行的 portable 包 + 自测报告(源码随仓库交付) |
| 9 | 约束 | 可装 npm 依赖;可用 PowerShell 做桌面自动化;**运行期完全离线**;适配 125%/150% 缩放 |
### 需要你知晓的三个默认取舍
1. **产品包含 1016 全量目录**:可执行项正常拖拽;未接入项灰标「待接入」,可拖入画布但运行时在该节点给出 `capability_not_implemented` 与替代建议。目录数据随包(约 1.8 MB)。
2. **OCR 不阻塞验收**:先做本地图像查找/点击(OpenCV 模板匹配,离线);OCR 用 Windows 自带引擎作为可选加分项,做不成就在自测报告中标注未交付。
3. **厂商资料只做行为参考**:`实在ai分析结果` 与 `Rea-AgentShizai-cankao` 用于校准流程格式、组件字段、执行语义、错误与选择器模型;不复制厂商代码/二进制/模型/前端资产(授权未确认,见复用审查)。
---
## 1. 交付定义(Definition of Done)
三天结束时,必须能在一台干净的 Windows 11 x64 机器上:
1. 解压 `RPA-Studio-0.1.0-win-x64.zip`,双击 `RPA Studio.exe` 启动,**无需联网、无需 Node/Python**(Word/图像为可选能力,缺依赖时显式降级)。
2. 左侧组件库可见 **1016 项**,按 11 组与「全部/可执行/待接入」筛选;可执行项正常拖拽,待接入项灰标但仍可拖入画布。
3. 新建流程 → 从左侧组件库拖入 ≥6 个能力 → 连线 → 配置参数、变量、超时/重试/失败策略 → 保存。
4. 点「运行」后流程真实执行,画布节点按状态高亮,运行面板显示每步日志/耗时/变量/截图;待接入节点在此处给出明确失败信息。
5. 跑通一条端到端流程:**内置浏览器打开本地夹具页 → 填表提交 → 抓取结果表格 → 写入 Excel → 按金额分支 → 桌面通知/发信**。
6. 故意制造失败(选择器不存在/文件不存在/待接入组件),流程在该节点停止,日志给出可分类错误码、原因、建议与证据。
7. 自测报告列出全部用例与通过情况,明确"可执行/待接入"清单。
**非目标(三天)**:移动端、机械臂、验证码、云端 OCR、并行分支、分布式、审批流、多租户、市场、录制器、AI 生成流程。
---
## 2. 范围基线
### 2.1 产品内可执行组件(计划 130,验收目标 120,保底 90;目录仍展示 1016 全量)
| 族 | 目标 | 代表组件 | 实现要点 |
|---|---:|---|---|
| 控制/触发 | 14 | `logic_control_if_else`、`logic_control_count_for`、`logic_control_array_for_v3`、`logic_control_while`、`logic_control_try_catch`、`logic_control_var`、`logic_control_break_for`、`logic_control_next_for`、`logic_control_stop`、`Trigger.Manual`、`Trigger.FileArrival` | 自研解释器;容器父子节点;IF 双出口;循环带最大次数保护 |
| 浏览器/元素 | 28 | `WebBrowser_CreateV3`、`WebBrowser_GoUrl`、`WebBrowser_Switch`、`Element_Click_V6`、`Element_SetValue_V6`、`Element_GetText_V5`、`Element_Exists`、`Element_SelectOperation_v1`、`WebBrowser_RunJs`、`WebBrowser_SaveAs` | 内嵌 `WebContentsView` + CDP;移植 `C:\rpa-demo\actions.js`;五级定位;拾取器 |
| 文件 | 14 | `File_Read`、`File_Write`、`File_Copy`、`File_File_Move`、`File_DeleteFile`、`File_Rename`、`File_FileExists`、`File_GetFileList`、`File_MakeDir`、`File_CompressFile`、`File_DecompressFile`、`File_GetFileInfo` | Node `fs` + `adm-zip` |
| Excel/CSV | 18 | `Excel_Cs_NewV5`、`Excel_Cs_OpenV4`、`Excel_Cs_ReadContentV2`、`Excel_Cs_WriteCell`、`Excel_Cs_WriteRange`、`Excel_Cs_ReadRangeV2`、`Excel_Save`、`Excel_Cs_SaveAs`、`Excel_GetSheetsName`、`Excel_Cs_Insert`、`Excel_Cs_Rename`、`CSV_Read`、`CSV_Write` | `exceljs` 文件级;COM 只用于"可见打开"演示 |
| 数据/文本 | 28 | `StrUtil_InStr/CutOutStrV1/SplitStr/Replace/CaseChange/Trim/Len/Left/Right/Mid`、`Time_GetDate/FormatNew/DateOffset`、`List_CreateList/InsertList/ListLength/ListSorted`、`Dict_Create/GetItem/SetItem/Keys`、`Regex_Test/FindAll`、`Math_Round/Abs`、`JSON_Parse/Stringify`、`Basic_SetVariable/ConvertNumber` | 纯 JS,表驱动批量接入 |
| 桌面 | 16 | `Keyboard_Send_V1`、`Keyboard_PressNew`、`Mouse_Click`、`Mouse_MoveTo`、`Mouse_DragTo`、`Mouse_RollTo`、`Window_SetActive`、`Window_GetActive`、`Window_WindowExists`、`Window_CloseWindow`、`App_Run`、`Clipboard_GetText`、`Clipboard_SetText`、`Dialog_Notice`、`Dialog_MsgBox`、`System_ExecuteCommand` | PowerShell 5.1 常驻桥:UIA + `SendInput` + user32 |
| 网络/邮件 | 4 | `Http_Get`、`Http_Post`、`Mail_Send`、`Mail_UnionSend` | `fetch` + `nodemailer` |
| PDF/Word | 5 | `PDF_GetPdfPageNum`、`PDF_GetPdfText`、`PDF_ConcatPdf`、`Word_New`/`Word_Write`(含保存) | `pdf-lib` + `pdfjs-dist`;Word 走 COM |
| 图像 | 3 | `Image_Screenshot`、`Image_Find`、`Image_Click` | 截屏 + 模板匹配(OpenCV/Python 3.10 已装) |
> 说明:
> 1. 注册数以"函数名 → 实现"计;同一实现可挂多个组件变体(如 `Element_Click_V6` 与其变体)。最终以自测报告为准,未通过的不计入;**保底 90 项**是验收底线。
> 2. 目录展示 **1016 全量**(997 + 16 + 3)。上表是"能真实执行"的子集;其余组件灰标「待接入」,属性面板仍可查看/编辑元数据,运行时给出 `capability_not_implemented`。
> 3. 组件字段与校验从 `componentsNew.json` 的 `definition.args0` 派生:`vType`(rpa_str/int/float/bool/list/dict/excel/hWeb/window/any)、`validatorRules`、`displayParam`、`tip`、`isAdvanced`。
### 2.2 明确不交付
- 移动端/外设/机械臂/U 盾/远程桌面(46+ 项)
- OCR 验证码、NLP、AIGC、云服务类组件
- SAP/IE/Java 应用/浏览器扩展通道
- 并行、子流程跨文件、审批、多租户、云同步
### 2.3 路线图(三天后)
| 阶段 | 内容 |
|---|---|
| P2 | 剩余组件接入;厂商 `.task` 导入/导出;完整选择器结构(commonProps/selector[])兼容 |
| P3 | 桌面 UIA 元素拾取、窗口选择器、图像 OCR、邮件 IMAP |
| P4 | 数据库/FTP/SAP、子流程与并行、审批与证据中心 |
| P5 | 与 `Agent-Employee` 的 Workflow/插件契约对齐、能力包分发 |
### 2.4 两份参考资产怎么用(2026-10-26 补充)
**结论:高价值行为参考,不做代码复制。** 它们解决四个关键问题:流程格式、组件定义、执行语义、错误与选择器模型。
| 资产 | 位置 | 用途 | 边界 |
|---|---|---|---|
| 复用审查(决策入口) | `实在ai分析结果/01-总览与结论/开发参考与复用审查.md` | 判断哪些机制可参考、哪些不能 | 厂商代码授权未确认,只参考机制 |
| 组件字典 | `Rea-AgentShizai-cankao/code/rpa-engine/componentDicts/componentsNew.json`(997 项) | 补全 `vType`、`validatorRules`、`displayParam`、`tip`、`isAdvanced`,驱动类型化表单与校验 | 仅构建期派生,不随包分发原始 JSON |
| 真实工程样例 | 同目录 `code/app/resources/examples/*.zip`(网页填表、新闻抓取、微信回复) | 对照 `.task` / `project.flow` / `globalVariable` / 生成的 `projects/flow_modules/*.py`,校准流程与执行语义 | 只读行为参考,不复制业务内容与截图 |
| 任务格式 | 样例包内 `*.task` | `componentModelEntities` + `MAIN_COMPONENT.statements` + `args0`/`options0`;据此定义节点 `options` 与容器 `statements` | 三天不做导入/导出 |
| 画布格式 | 样例包内 `project.flow` | 节点 x/y/width/height、shape、edges、anchors、`reTryCount`、`errorVar` | 厂商是"块级画布 + 块内步骤",我们是单层画布 |
| 执行与错误语义 | `Rea.../code/derived-engine-core`(203 份源)+ `rpa-engine/conf/err_code/err_codes.py` | 重试/超时/skip/log 规则、错误 reason+recommendation、元素变量插值 | 不复制实现;用公开库重写 |
| 选择器模型 | 样例 `globalVariable` 的 `SHIZAI_ELEMENT_DICT`;`common/util/elements_util.py` | `{commonProps, selector:[{name, props:[{name,value,pattern}]}]}`;`${}` 插值;pattern 枚举 | v1 存简化选择器,P2 兼容完整结构 |
| 组件包 | `rpa-engine/311_32/packages/*`(54 包,`.pyc` + `.pyi` + `config.json`) | 查函数签名、表单 schema、参数顺序 | 字节码不是源码,不复制 |
| 前端/插件 | `code/app/resources/*`、`code/support/*` | UI 交互、浏览器扩展安装、Office 插件行为参考 | 资产与二进制不复制 |
**采纳到契约里的具体设计:**
1. 节点 `options` 对齐 `options0`:`delayBeforeMs`、`delayAfterMs`、`timeoutMs`、`retryCount`(1..100)、`retryIntervalMs`(1..600s)、`skipOnError`、`logException`、`outputVar`。
2. 容器子节点保存为有序 `statements`(等价厂商 `input_statement`);画布仍用连线 + 父子关系,保存时编译为 statements。
3. 变量类型映射:`rpa_str/int/float/bool/list/dict/excel/hWeb/window/any` → 我们的 `string/number/boolean/list/dict/excel/browser/window/any`。
4. 错误对象扩展为 `{ code, reason, recommendation, location:{flowId,nodeId,componentId,arg} }`;失败日志展示"原因 + 建议 + 定位"。
5. 选择器 v1 存 `{kind:'css'|'aria'|'text', value, name?}`;拾取器同时产出厂商式候选,P2 再落完整 `commonProps/selector[]`。
6. 用真实样例做回归夹具:在本地夹具页复刻"读 Excel → 填网页 → 提交"的链路,验证语义,不复制内容。
7. 执行策略对齐:`skipOnError` 记错误日志并继续;`retryCount` 仅对可重试错误生效;参数校验失败不重试;`timeoutMs` 为整次调用总时限。
**明确不做:** 复制厂商 `.pyc/.dll/.exe/模型/前端资产`;调用厂商云服务;内置厂商运行时;三天内做 `.task` 导入/导出。
---
## 3. 产品形态与信息架构
```
┌──────────────────────────────────────────────────────────────┐
│ 顶栏:Logo | 流程名 | 保存状态 | 撤销/重做 | 缩放 | ▶运行 ■停止 │
├─────────┬──────────────────────────────────────┬─────────────┤
│ 左栏 │ 画布(点阵/节点/连线/容器/MiniMap) │ 右栏 │
│ 流程列表 │ │ 属性/变量/运行 │
│ 组件库 │ │ │
│ 变量 │ │ │
│ 运行历史 │ │ │
├─────────┴──────────────────────────────────────┴─────────────┤
│ 底部运行抽屉:时间 | 节点 | 状态 | 耗时 | 输出/错误 | 截图 │
└──────────────────────────────────────────────────────────────┘
```
页面:
1. **首页/流程列表**:新建、导入、最近运行、`1016 项目录 / 可执行 N 项`。
2. **编辑器**(核心):左组件库 + 中画布 + 右属性/运行。
3. **运行历史**:run 列表、状态、耗时、日志与证据查看。
4. **设置**:浏览器域名白名单、证据开关、超时、外观。
组件库展示 **1016 全量**,分组沿用 11 组;顶部显示「1016 项目录 · 可执行 N 项」,并提供「全部 / 可执行 / 待接入」筛选。可执行项正常拖拽;待接入项灰标、可拖入但运行时明确失败。
---
## 4. 技术架构
### 4.1 技术栈
| 层 | 选型 |
|---|---|
| 壳 | Electron 31.7.7(复用本机缓存) |
| 构建 | electron-vite + Vite + React 18 + TypeScript |
| 画布 | `@xyflow/react`(React Flow) |
| 状态 | Zustand(flowStore / runStore / uiStore) |
| 样式 | CSS 变量 + 设计令牌,无 UI 框架依赖 |
| 存储 | 本地 JSON(`userData`) |
| 测试 | Vitest(单元)+ `scripts/selftest.mjs`(Electron 主进程集成) |
### 4.2 目录结构
```
D:\RPA
├─ package.json
├─ electron.vite.config.ts
├─ resources/
│ ├─ catalog.json # 1016 全量 + 可执行状态 + 类型/校验元数据
│ ├─ fixture/ # 本地演示站点(离线)
│ └─ desktop-bridge.ps1 # 桌面自动化桥
├─ src/
│ ├─ shared/ # 类型、常量、错误码(主/渲染共用)
│ ├─ main/
│ │ ├─ index.ts # 生命周期、窗口、协议
│ │ ├─ ipc.ts # 类型化 IPC 路由
│ │ ├─ store/ # flows/runs/settings/secrets
│ │ ├─ engine/ # compiler/runtime/expression/context
│ │ ├─ capabilities/ # control/browser/files/excel/data/desktop/network/office/image
│ │ ├─ browser/ # view 管理、CDP、picker、evidence
│ │ ├─ bridge/ # PowerShell 客户端、Python 图像客户端
│ │ └─ fixture-server.ts
│ ├─ preload/index.ts # contextBridge 白名单 API
│ └─ renderer/
│ ├─ pages/ # Home/Editor/Runs/Settings
│ ├─ canvas/ # FlowCanvas/NodeCard/ContainerNode/Palette/edges
│ ├─ panels/ # Properties/Variables/RunPanel/Evidence
│ ├─ state/
│ └─ styles/
├─ examples/ # 示例流程 JSON
├─ scripts/ # build-catalog / selftest / package
├─ docs/
└─ release/ # portable 产物(不入 Git)
```
### 4.3 进程与边界
- **主进程**:唯一拥有文件系统、子进程、浏览器视图、执行引擎;渲染进程不能直接 `require`。
- **预加载**:`contextIsolation: true`,只暴露 `flow.* / run.* / catalog.* / browser.* / desktop.* / settings.*` 白名单方法。
- **渲染进程**:只做 UI 与状态;运行状态通过 `run:event` 订阅。
- **PowerShell 桥**:常驻子进程,行分隔 JSON;超时/崩溃自动重启一次并显式报错。
- **Python 图像桥**:按需启动,仅图像查找用;不可用时功能降级。
### 4.4 运行期离线
- 所有依赖打包进 asar/资源目录;不加载 CDN 字体/脚本。
- 内置浏览器只访问白名单(默认 `127.0.0.1`、`localhost`)。
- 夹具站由主进程在 `127.0.0.1` 随机端口启动。
- HTTP/邮件属于用户显式配置的外部系统,不是云依赖;演示默认用本地 mock。
---
## 5. 关键契约
### 5.1 流程定义(`FlowDefinition` v1)
```jsonc
{
"schemaVersion": 1,
"id": "flow_...",
"name": "网页订单录入",
"description": "",
"variables": [
{ "name": "amount", "type": "number", "default": 0, "scope": "flow" }
],
"nodes": [
{
"id": "n1",
"componentId": "Trigger.Manual",
"functionName": "Trigger.Manual",
"position": { "x": 120, "y": 80 },
"values": {},
"options": { "outputVar": "", "timeoutMs": 30000, "skipOnError": false }
},
{
"id": "n2",
"componentId": "logic_control_if_else",
"functionName": "logic_control_if_else",
"position": { "x": 120, "y": 260 },
"values": { "condition": "${amount} > 1000" },
"parentId": null
},
{
"id": "n3",
"componentId": "Element_Click_V6",
"position": { "x": 60, "y": 380 },
"parentId": "n2",
"slot": "statement_if",
"values": { "uiSelector": "css:#submit" },
"options": {
"outputVar": "",
"delayBeforeMs": 200,
"delayAfterMs": 200,
"timeoutMs": 15000,
"retryCount": 0,
"retryIntervalMs": 1000,
"skipOnError": false,
"logException": true
},
"enabled": true
}
],
"edges": [
{ "id": "e1", "source": "n1", "target": "n2", "sourceHandle": "out", "targetHandle": "in" }
],
"meta": { "createdAt": "...", "updatedAt": "...", "appVersion": "0.1.0" }
}
```
### 5.2 容器语义
| 节点 | 出口 | 子槽 | 执行 |
|---|---|---|---|
| `logic_control_if_else` | `true` / `false` | `statement_if` / `statement_else` | 条件为真走真槽链,否则走假槽链,之后汇合到主链下一节点 |
| `logic_control_count_for` | `after` | `count_for_fn` | 从 start 到 end 按 step 循环,每轮执行子链 |
| `logic_control_array_for_v3` | `after` | `array_for_fn` | 遍历列表/字典,暴露 `item`、`index` |
| `logic_control_while` | `after` | `if_for_fn` | 条件为真循环,最大 1000 次 |
| `logic_control_try_catch` | `after` | `try_fn` / `except_fn` / `else_fn` | 捕获异常,暴露 `${error}` |
子槽内节点按"连线优先、无连线按 y 排序"形成链;禁止跨槽连线;容器可嵌套。
### 5.3 表达式
- 文本字段支持 `${expr}` 插值;IF/循环条件为纯表达式。
- 语法:字面量、变量、成员/下标、算术/比较/逻辑/三元、括号。
- 函数白名单:`len, upper, lower, trim, replace, split, join, contains, startsWith, endsWith, matches, number, string, round, abs, min, max, random, now, today, formatDate, json, parseJson`。
- **不使用 `eval` / `new Function`**;自研 tokenizer + Pratt parser,错误定位到字符位置。
- 变量缺失:默认报 `expression_error`;可在设置中改为空串并告警。
### 5.4 运行事件
```ts
type RunEvent =
| { type: "run.started"; runId: string; flowId: string; startedAt: string }
| { type: "node.started"; runId: string; nodeId: string; attempt: number }
| { type: "node.log"; runId: string; nodeId: string; level: "info"|"warn"|"error"; message: string }
| { type: "node.finished"; runId: string; nodeId: string; status: NodeStatus; output?: unknown; errorCode?: string; durationMs: number; evidence: string[] }
| { type: "run.finished"; runId: string; status: "succeeded"|"failed"|"cancelled"; durationMs: number; error?: { nodeId: string; code: string; message: string } }
```
`NodeStatus = idle | running | succeeded | failed | skipped | cancelled`
### 5.5 错误模型
封闭 `code` 集合:
`not_found`、`timeout`、`ambiguous`、`blocked_url`、`capability_not_implemented`、`expression_error`、`validation_error`、`io_error`、`office_error`、`desktop_error`、`network_error`、`cancelled`、`unknown`
运行结果中的错误对象(对齐厂商 `err_codes.py` 的"原因 + 建议"风格):
```ts
type RunError = {
code: string;
reason: string; // 中文原因,如"未找到元素:css:#submit"
recommendation?: string; // 中文建议,如"检查选择器或等待条件"
location?: { flowId?: string; nodeId?: string; componentId?: string; arg?: string };
detail?: string; // 原始错误/堆栈,仅日志
};
```
### 5.6 能力注册表
```ts
type Capability = {
functionName: string; // 目录 functions[0].function
componentIds: string[]; // 允许挂载的组件
run(ctx: RunContext, args: Record<string, unknown>, node: FlowNode): Promise<CapabilityResult>;
// 可选:字段归一、选择器校验、证据策略
};
```
`capabilities/manifest.ts` 汇总所有已注册函数;`scripts/build-catalog.mjs` 生成 `resources/catalog.json`:**1016 全量** + `implemented: true|false` + 归一后的字段/类型/校验元数据。目录不再裁剪,未实现项由运行时守卫拦截。
---
## 6. 画布交互规格
### 6.1 组件库
- 搜索(中文名/英文 ID/标签/描述),11 组筛选,状态筛选(全部/可执行/待接入),虚拟滚动。
- 每项:图标、名称、ID、字段数、分类色条、状态点(绿=可执行,灰=待接入)。
- 拖拽:HTML5 `dragstart` 写入 `componentId`;画布 `drop` 用 `screenToFlowPosition` 定位。待接入项同样可拖拽。
- 双击:追加到当前选中节点之后。
- 目录为空/无匹配:空态提示。
### 6.2 节点
- 卡片:图标 + 名称 + 副标题(关键参数摘要)+ 输出变量胶囊 + 状态点 + 错误徽标。
- 待接入节点:整体降饱和 + 右上角「待接入」徽标 + tooltip「当前版本无执行器,运行到此会停止」。
- 选中/多选/框选;拖动;吸附网格;复制/粘贴/删除;右键菜单(复制、禁用、删除、查看定义)。
- 连接点:左侧输入,右侧输出;IF 两个输出(真/假);循环一个 `after` 输出 + 容器体。
- 连线:贝塞尔/折线;非法连接(自连、重复、跨容器、成环)即时拒绝并提示。
### 6.3 容器
- IF/循环以"分组节点"呈现:标题 + 折叠 + 子区域(虚线框)。
- 拖入子区域自动设置 `parentId` 与 `slot`;拖出清除。
- 容器高度随子节点自适应,可手动调整。
- 子槽为空时显示拖拽提示。
### 6.4 画布操作
滚轮缩放(0.3–2)、空格/中键平移、`Ctrl+0` 适配、MiniMap、背景点阵、框选、对齐辅助线(可选)。
快捷键:`Ctrl+S` 保存、`Ctrl+Z/Y` 撤销重做、`Delete` 删除、`Ctrl+D` 复制、`Ctrl+Enter` 运行、`Esc` 取消选择。
### 6.5 属性面板
Tab:属性 / 变量 / 运行。
属性分区:
1. **基本**:节点名称、组件 ID、启用、备注;待接入项显示「当前版本无执行器」。
2. **输入**:按目录字段渲染(12 类控件);必填标记;默认值;占位提示;高级字段折叠;字段类型来自 `vType`。
3. **输出**:输出变量名(自动建议)、变量类型(从 `vType` 推断)。
4. **失败策略**:超时(默认 30s,上限 7 天)、重试次数(0–100)、重试间隔(1–600s)、失败后跳过、记录异常、前置/后置延时。
5. **证据**:是否截图、是否保存输出。
字段控件映射(把 200+ `displayType` 归并):
| displayType 关键词 | 控件 |
|---|---|
| text-expression / text / coloredText / label | 文本 + 变量插入 |
| textArea / multipleTextArea / quillInput | 多行文本 |
| number / integerInputNumber / inputNumber / sliderBar / rangeInput | 数字 |
| dropDownSelect / defeasibleDropDownSelect / dynamicSelect | 下拉 |
| radioGroup / radio / radioLeft / buttonGroup / coloredRadio | 单选 |
| checkbox / switchControl / multifileSwitch | 布尔/多选 |
| selectFile / multiSelectFileOrDir / saveAsDialog / selectControllerFile | 文件/目录选择 |
| comboSelector / selectUi / uiwindowselector / phoneSelector | 元素选择器(浏览器拾取) |
| code / jsonEditor / codeInput | 代码/JSON 编辑器 |
| encryption / designPassword | 密钥(Electron safeStorage) |
| 其它 | 文本兜底 + "原始类型"提示 |
校验:必填、数字格式、变量名合法、选择器非空、文件路径存在(可关)。
---
## 7. 执行内核
### 7.1 编译期校验
1. 组件在目录中;未接入项标记 `pending`,允许保存;运行前提示「N 个待接入节点」,执行到该节点报 `capability_not_implemented` 并停止。
2. 必填字段非空(表达式允许 `${}`);类型与 `vType` 粗校验。
3. 失败策略范围:`timeoutMs` 0–604800000、`retryCount` 0–100、`retryIntervalMs` 1000–600000、延时 ≥0。
4. 连线合法:无环、无悬空、容器槽匹配。
5. 触发器唯一且在起点。
6. 变量引用可解析(未定义变量警告/错误可配)。
失败返回结构化 `validation_error[]`(含 `reason/recommendation/location`),画布定位到节点。
### 7.2 运行时
```
run(flow):
ctx = { vars: defaults, secrets, runId, signal, evidenceDir, logger }
plan = compile(flow)
emit run.started
try:
executeChain(plan.entry, ctx)
emit run.finished(succeeded)
catch AbortError: emit run.finished(cancelled)
catch e: emit run.finished(failed, node, code)
```
`executeChain`:顺序执行;遇容器递归;每节点:
1. 解析 `values`(表达式插值/类型转换)。
2. 应用 `delayBeforeMs`。
3. 执行 `capability.run`,总时限 `timeoutMs`;可重试错误按 `retryCount/retryIntervalMs` 重试(校验错误、`capability_not_implemented`、`blocked_url` 不重试)。
4. 写入 `outputVar`。
5. 应用 `delayAfterMs`;记录日志/证据/耗时。
6. 失败时:`skipOnError=true` → 记错误日志、节点状态 `skipped`、继续;否则按错误停止整个运行。
7. `logException=true` 时把堆栈写入运行日志(不写入 UI 摘要)。
### 7.3 取消
`AbortController` 贯穿:浏览器命令、PowerShell 请求、HTTP、文件循环都检查 `signal`;停止后不再启动新节点。
### 7.4 证据
- 浏览器/桌面节点可截图(PNG)。
- 每节点输出 JSON 摘要(截断 8KB)。
- 运行目录:`userData/runs/<runId>/run.json` + `nodes/<nodeId>.json` + `evidence/*.png` + `run.log`。
- UI 只显示相对路径;证据查看器内置。
---
## 8. 运行时实现
### 8.1 浏览器
- 主窗口右侧/独立窗口嵌入 `WebContentsView`,地址栏 + 前进后退 + 刷新 + 关闭。
- 移植 `actions.js`:`navigate/click/type/select/hover/scroll/wait/extract/screenshot/tabs/runJs`;保留统一结果、错误码、后置条件、`actionId` 去重。
- 定位:`testid → aria → text → css → xpath`;导出时 xpath 仅可等价转 css。
- 选择器值 v1 存 `{ kind, value, name? }`;拾取器同时生成厂商式候选项(`commonProps + selector[]` 的简化版),P2 再落完整结构。
- 拾取器:注入 overlay,`mousemove` 高亮、点击捕获并 `preventDefault`,生成候选定位,回填属性面板;元素库(多元素复用)留到 P2,v1 直接写入节点。
- 域名白名单在任何页面动作前校验;默认仅 `127.0.0.1`/`localhost`。
- 高影响动作(click/type/下载/上传)运行前弹一次确认(可设置关闭)。
### 8.2 桌面(PowerShell 桥)
`resources/desktop-bridge.ps1`,stdin/stdout 行分隔 JSON:
| 命令 | 说明 |
|---|---|
| `win.list` / `win.find` / `win.activate` / `win.state` / `win.rect` / `win.close` | EnumWindows / SetForegroundWindow / ShowWindow |
| `uia.find` / `uia.invoke` / `uia.setValue` / `uia.getText` | UIAutomationClient(元素优先) |
| `input.click` / `input.move` / `input.drag` / `input.scroll` | user32 `SendInput` 绝对坐标 |
| `input.type` / `input.key` / `input.hotkey` | `SendInput` Unicode / 虚拟键 |
| `clip.get` / `clip.set` | WinForms Clipboard |
| `screen.capture` | System.Drawing |
| `app.run` | `Start-Process` |
| `sys.env` / `sys.cmd` | 环境变量 / 命令执行(超时+输出截断) |
DPI:
- 桥启动时 `SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2)`。
- 所有坐标使用物理像素;Node 侧用 `screen.getPrimaryDisplay().scaleFactor` 换算。
- 125%/150% 必须跑冒烟用例。
### 8.3 Excel / PDF / Word
- Excel:`exceljs` 读写、sheet 增删改、区域读写、保存/另存;COM 只做"可见打开/关闭"(可选开关)。
- CSV:自研解析(带引号/换行)。
- PDF:`pdfjs-dist` 取文本/页数;`pdf-lib` 合并/新建。
- Word:PowerShell COM(新建/写入/保存/关闭/读取全文);无 Word 时显式报 `office_error`。
### 8.4 图像
- 截屏:Electron `desktopCapturer` 或 PowerShell。
- 模板匹配:优先 Python 3.10 + OpenCV `matchTemplate`(本机已装);未检测到 Python 时用内置 JS 降级(`pngjs` 降采样 + 归一化互相关);两者都不可用时,该组件在目录中禁用并提示 `capability_unavailable`,不影响其他功能。
- OCR:可选,Windows.Media.Ocr;不达标就标注未交付。
> Word 组件依赖本机安装 Word(COM);无 Word 时返回 `office_error`,不影响其他组件。Excel 文件级读写用 `exceljs`,不依赖 Office。
### 8.5 HTTP / 邮件
- HTTP:`fetch`(Node 22 内置),支持方法/头/查询/Body/超时/保存响应。
- 邮件:`nodemailer` SMTP;收件用 `imapflow`(如时间允许);演示用本地 mock SMTP。
---
## 9. 数据存储与安全
| 项 | 方案 |
|---|---|
| 流程 | `%APPDATA%/rpa-studio/flows/<id>.json`,原子写(临时文件+rename) |
| 目录 | `resources/catalog.json`(1016 全量 + 状态 + 字段元数据),启动加载,只读 |
| 运行 | `runs/<runId>/`,保留最近 50 次,自动清理 |
| 设置 | `settings.json`(白名单、超时、证据、缩放) |
| 密钥 | `safeStorage` 加密后存 `secrets.json`,UI 只显示引用名 |
| 日志 | 主进程滚动日志 + 运行日志;单文件 5MB 截断 |
| 安全 | 渲染进程无 Node;IPC 白名单;表达式无 eval;浏览器协议仅 http/https;文件操作不做沙箱(本地工具定位) |
---
## 10. 三天排期(逐半天 + 门禁)
> 今天 10/26 从 10:00 起算;每天按 12~14 小时安排;每半天一个可演示产物。
### Day 1(10/26):工程 + 目录 + 画布 + 属性
| 时间 | 任务 | 产出/门禁 |
|---|---|---|
| 10:00–11:00 | 脚手架(electron-vite/React/TS)、窗口、三栏布局、令牌、路由 | `npm run dev` 出窗口 |
| 11:00–12:30 | 目录管线:解析 1016 → 合并 `componentsNew.json` 的 `vType/validatorRules/tip` → 分组/状态/字段归一 → `catalog.json`;组件库搜索/分组/状态筛选/虚拟滚动 | 左栏 1016 项可搜可拖,灰标正确 |
| 13:30–17:00 | 画布:拖入、节点卡、连线、选择/删除/复制、缩放/平移/MiniMap、撤销重做 | 6 节点可连线 |
| 17:00–19:30 | 容器:IF/计次/遍历 分组节点、子节点拖入、槽位、尺寸自适应 | 节点可拖进 IF 分支 |
| 20:00–22:00 | 属性面板:控件映射、校验、输出变量/类型、失败策略(超时/重试/跳过/延时)、变量面板、表达式插入 | 配置可保存到节点 |
| 22:00–23:30 | 保存/加载、示例流程、空态/错误态、Day1 演示 | 重启后流程还原 |
| **门禁 G1** | 拖 6 节点 + IF + 循环 → 配参数 → 保存 → 重启还原 → 截图留档 | 不通过不进入 Day2 |
### Day 2(10/27):执行内核 + 浏览器 + 桌面
| 时间 | 任务 | 产出/门禁 |
|---|---|---|
| 09:00–12:00 | 表达式解析器、编译器(校验/容器/链/待接入守卫)、运行时(上下文/事件/取消/失败策略:超时/重试/跳过/延时)、Vitest | 直线/IF/循环/重试用例通过 |
| 13:00–16:30 | 浏览器:WebContentsView、CDP 动作移植、拾取器、证据、本地夹具站 | 打开夹具页→点击→输入→取文本 |
| 16:30–19:30 | 桌面桥:PowerShell 常驻、键鼠/窗口/剪贴板/对话框/命令、DPI 换算 | 记事本输入 + 窗口激活 |
| 20:00–23:30 | 能力批量:控制(14)+ 文件(14);运行面板 v1 | 单节点用例批量通过 |
| **门禁 G2** | 一条"浏览器 3 步 + 桌面 2 步"流程可运行,事件流与日志正确 | 不通过则 Day3 砍 PDF/Word |
### Day 3(10/28):Office/网络 + 联调 + 打包
| 时间 | 任务 | 产出/门禁 |
|---|---|---|
| 09:00–11:00 | Excel/CSV(18) | 读写 xlsx、多 sheet、另存 |
| 11:00–12:00 | 数据/文本包(28,表驱动) | 字符串/时间/列表/字典/正则/数学/JSON 用例通过 |
| 13:00–14:30 | PDF/Word(5)、HTTP/SMTP(4) | PDF 合并/取文本、Word 写入、本地 mock 通过 |
| 14:30–15:30 | 图像查找(3,OpenCV) | 模板匹配坐标/置信度达标 |
| 15:30–17:30 | 运行面板完善(变量/证据/重试/停止/待接入定位)、E2E 夹具流程、错误态 | 场景 A 跑通 |
| 17:30–19:00 | 125%/150% 缩放冒烟、边界修复、性能(200 节点流程) | DPI 用例通过 |
| 19:00–21:00 | 打包 portable、干净目录冒烟、自测报告、README/演示脚本 | 双击 exe 可演示 |
| 21:00–22:00 | 缓冲/修复/交付整理 | 交付清单齐 |
| **门禁 G3** | 干净机器解压→运行→场景 A→失败用例→报告 | 最终验收 |
### 砍功能顺序(进度落后时)
1. FTP/数据库(本就不做)
2. Word(保留 PDF)
3. 邮件收件(保留发信)
4. 桌面 UIA(保留坐标键鼠)
5. 循环容器(保留 IF)
6. 图像/OCR
7. 目录组件数(可执行 120 → 80,保浏览器/文件/Excel/数据)
---
## 11. 自测与验收
`scripts/selftest.mjs` 以 Electron 主进程模式启动,逐项执行并写 `docs/自测报告.md`。
| # | 用例 | 期望 |
|---|---|---|
| T01 | 目录加载/分组/搜索/状态 | 1016 项齐全;可执行/待接入计数与注册表一致 |
| T02 | 画布增删/连线/容器 | 数据结构与几何正确 |
| T03 | 保存/加载/撤销 | 重启一致 |
| T04 | 表达式(算术/字符串/时间/成员) | 结果正确,错误定位 |
| T05 | 顺序执行 | 节点按序、变量传递 |
| T06 | IF 真/假分支 | 只执行对应分支 |
| T07 | 计次循环 | 次数/步长正确,可中断 |
| T08 | 遍历列表 | item/index 正确 |
| T09 | 失败策略:停止/重试/跳过/超时/延时 | 行为与配置一致;校验错误不重试 |
| T10 | 取消运行 | 在途动作中止,状态 cancelled |
| T11 | 浏览器导航/点击/输入/取文本 | 真实 DOM 变化,可信输入 |
| T12 | 元素拾取器 | 生成候选定位并可回填 |
| T13 | 浏览器截图证据 | 文件存在、相对路径 |
| T14 | 文件读/写/复制/删除/列表 | 临时目录结果正确 |
| T15 | Excel 写/读/另存 | 单元格值正确 |
| T16 | CSV 读/写 | 引号/换行正确 |
| T17 | PDF 页数/文本/合并 | 结果与样本一致 |
| T18 | Word 写入/保存 | 文档存在且含文本 |
| T19 | 键盘输入记事本 | 文本一致 |
| T20 | 鼠标点击/窗口激活 | 目标窗口前台 |
| T21 | 剪贴板读写 | 往返一致 |
| T22 | 对话框/消息框 | 返回用户选择 |
| T23 | HTTP GET/POST | 本地服务响应正确 |
| T24 | 邮件发送(mock SMTP) | 服务器收到报文 |
| T25 | 图像查找 | 坐标/置信度达标 |
| T26 | 125%/150% 缩放 | 画布不裁切、点击不偏 |
| T27 | 断网启动 | 正常启动与运行离线流程 |
| T28 | 打包产物启动 | 双击可用,无缺失依赖 |
| T29 | 待接入组件拖入并运行 | 节点灰标;运行到该节点报 `capability_not_implemented`,含原因/建议/定位 |
| T30 | 目录元数据类型/校验 | `vType` 推断输出类型;必填校验生效 |
| T31 | 状态筛选/搜索 | 「全部/可执行/待接入」与关键词组合正确 |
报告格式:用例、能力、前置、步骤、期望、实际、结论、证据路径。**未通过项必须列出,不得隐藏。**
---
## 12. 打包与交付
```
release/RPA-Studio-0.1.0-win-x64/
RPA Studio.exe
resources/app.asar
resources/catalog.json
resources/fixture/...
resources/desktop-bridge.ps1
examples/*.json
README.md
version.json
RPA-Studio-0.1.0-win-x64.zip
SHA256SUMS.txt
docs/自测报告.md
```
- 使用 `@electron/packager`(portable 目录),避免 electron-builder 的签名/工具下载风险。
- 打包脚本校验:无 devDependencies、无 node_modules 泄漏、无 `.env`/密钥、资源齐全。
- 干净目录冒烟:复制到 `D:\tmp\rpa-smoke`,断网启动,跑示例流程。
- 版本清单记录源码提交号、构建时间、Electron 版本、可执行组件数。
---
## 13. 风险与应对
| 风险 | 概率 | 影响 | 应对 |
|---|---|---|---|
| 三天做不完 | 高 | 交付缩水 | 按砍功能顺序;G1/G2 门禁强制 |
| 桌面 DPI/焦点 | 中 | 演示失败 | 桥声明 DPI、UIA 优先、坐标兜底、双缩放冒烟 |
| Excel COM 弹窗/慢 | 中 | 卡流程 | 文件级 exceljs;COM 仅可选 |
| 浏览器站点兼容 | 中 | 登录/框架页失败 | 只承诺内置浏览器 + 本地夹具;iframe/影子 DOM 显式报错 |
| 未注册组件误入 | 高 | 运行中断 | 全量目录 + 灰标 + 预运行「N 个待接入」提示 + 运行期 `capability_not_implemented`(含替代建议) |
| npm/Electron 下载 | 低 | 阻塞 | 源已验证;锁版本;Electron 用本地缓存 |
| C 盘空间 | 中 | 构建失败 | 全部在 D 盘;缓存指向 D |
| 需求蔓延 | 高 | 延期 | 本文件为基线,新需求进 P2+ 清单 |
---
## 14. 交付清单
- [ ] `D:\RPA` 源码仓库(git,提交按 G1/G2/G3 打点)
- [ ] `release/RPA-Studio-0.1.0-win-x64.zip`(可双击)
- [ ] `docs/自测报告.md`(31 用例 + 可执行组件清单)
- [ ] `docs/操作手册.md`(10 分钟上手)
- [ ] `examples/` 两条示例流程
- [ ] 本实施方案 + 现状分析
---
## 15. 待确认的默认假设(不回复即按此执行)
1. 产品暂定名 **RPA Studio**,可一处配置改名。
2. 组件库展示 **1016 全量**;未接入项灰标、可拖拽、运行时明确失败;可执行目标 120(保底 90)。
3. 演示场景默认"本地夹具页:填表→抓表格→写 Excel→按金额分支→通知/发信",后续可换成你的系统。
4. OCR 为加分项,不阻塞验收;图像查找优先。
5. 运行期离线;构建期允许联网装依赖。
6. 两份厂商参考资料只做行为参考;不复制代码/二进制/模型/前端资产;三天不做 `.task` 导入/导出。

41
electron.vite.config.ts Normal file
View File

@ -0,0 +1,41 @@
import { resolve } from "node:path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
"@shared": resolve("src/shared")
}
},
build: {
rollupOptions: {
input: { index: resolve("src/main/index.ts") }
}
}
},
preload: {
plugins: [externalizeDepsPlugin()],
resolve: {
alias: {
"@shared": resolve("src/shared")
}
},
build: {
rollupOptions: {
input: { index: resolve("src/preload/index.ts") }
}
}
},
renderer: {
plugins: [react()],
resolve: {
alias: {
"@shared": resolve("src/shared"),
"@renderer": resolve("src/renderer/src")
}
}
}
});

8654
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "rpa-studio",
"version": "0.1.0",
"private": true,
"description": "Canvas-based RPA desktop application",
"main": "./out/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:local": "node scripts/dev-safe.mjs",
"start:local": "node scripts/start.mjs",
"build": "electron-vite build",
"preview": "electron-vite preview",
"typecheck": "tsc --noEmit -p tsconfig.json",
"catalog": "node scripts/build-catalog.mjs",
"package": "node scripts/package.mjs",
"test": "vitest run"
},
"dependencies": {
"@xyflow/react": "^12.12.0",
"adm-zip": "^0.5.16",
"exceljs": "^4.4.0",
"lucide-react": "^0.468.0",
"nodemailer": "^6.9.16",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"pngjs": "^7.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/packager": "^18.3.6",
"@types/adm-zip": "^0.5.7",
"@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.17",
"@types/pngjs": "^6.0.5",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7",
"electron-vite": "^6.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.0",
"vitest": "^2.1.8"
}
}

1
resources/catalog.json Normal file

File diff suppressed because one or more lines are too long

346
scripts/build-catalog.mjs Normal file
View File

@ -0,0 +1,346 @@
/**
* 从 Agent-Employee 的 1016 项目录源数据生成产品目录。
*
* 输入:
* - shizai-catalog-data.js:997 厂商组件 + 16 原生控制节点
* - componentsNew.json:厂商原始定义(补 vType / validatorRules / tip / displayParam)
* 输出:
* - resources/catalog.json:1016 全量 + 归一化字段 / 分组 / 控件类型
*
* 说明:这里只派生元数据,不包含厂商运行时、二进制或业务数据。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, "..");
const SRC_CATALOG = process.env.RPA_CATALOG_SOURCE ||
"D:/code/Agent-Employee/code/frontend/apps/desktop/src/renderer/product-preview/pages/workflows/shizai-catalog-data.js";
const SRC_COMPONENTS_NEW = process.env.RPA_COMPONENTS_NEW ||
"D:/code/Agent-Employee/项目资料/Rea-AgentShizai-cankao/code/rpa-engine/componentDicts/componentsNew.json";
const OUT_FILE = path.join(ROOT, "resources", "catalog.json");
const GROUPS = [
{ id: "control", label: "编排控制", icon: "branch" },
{ id: "trigger", label: "流程触发", icon: "trigger" },
{ id: "app", label: "应用与系统", icon: "app" },
{ id: "web", label: "网页自动化", icon: "web" },
{ id: "office", label: "Office 与文档", icon: "table" },
{ id: "data", label: "数据处理", icon: "database" },
{ id: "file", label: "文件与传输", icon: "file" },
{ id: "desktop", label: "桌面与键鼠", icon: "mouse" },
{ id: "vision", label: "图像与 OCR", icon: "scan" },
{ id: "message", label: "网络与消息", icon: "send" },
{ id: "mobile", label: "移动端与设备", icon: "mobile" }
];
const PACKAGE_GROUPS = {
app: new Set(["App", "System", "Basic", "CustomConfig", "ScreenControl"]),
web: new Set(["WebBrowser", "Element"]),
office: new Set(["Excel", "Word", "PDF", "GoogleTable", "FileTypeConversion"]),
data: new Set(["Array", "Bivartable", "Collection", "CSV", "Database", "Databook", "Dict", "Ini", "JSON", "List", "Math", "Regex", "StrUtil", "Time"]),
file: new Set(["File", "FTP"]),
desktop: new Set(["Clipboard", "Dialog", "Keyboard", "Mouse", "Window", "SafeControlInput"]),
vision: new Set(["CV", "Image", "OCR", "OCR_BaiDu", "OcrOffline", "Mark"]),
message: new Set(["DataService", "Http", "Mail", "MessageNotification", "NLP", "NLP_XunFei", "Scenario"]),
mobile: new Set(["Mobile", "peripherals"])
};
const CONTROL_LABELS = {
condition: "条件表达式", var_name: "变量名称", var_value: "变量值", count_index: "计数变量",
count_start_value: "开始值", count_end_value: "结束值", count_step: "步长", if_for_condition: "循环条件",
return_value: "返回值", module_name: "子流程", call_type: "调用方式", fun_name: "方法", args: "输入映射",
fun_version: "版本", is_async: "是否异步", array_name: "列表变量", reverse: "是否倒序", start_index: "开始索引",
end_index: "结束索引", dictionary_name: "字典变量", event_type: "事件类型", uiSelector: "元素选择器",
designated_info: "指定信息", designated_obj: "指定对象", browser_type: "浏览器类型", is_exits: "存在条件",
monitor_type: "监控类型", monitor_frequency: "监控频率", input_data: "屏幕判断要求", log_process: "记录过程"
};
const TRIGGERS = [
{
id: "Trigger.Manual", name: "人工发起", description: "由用户在运行页手动发起流程。",
fields: [{ key: "entryName", label: "入口名称", displayType: "text-expression", defaultValue: "立即执行", required: true }]
},
{
id: "Trigger.Schedule", name: "定时触发", description: "按工作日或时间计划创建运行请求。",
fields: [
{ key: "mode", label: "计划方式", displayType: "dropDownSelect", defaultValue: "工作日", required: true, options: ["工作日", "每天", "每周", "Cron 表达式"] },
{ key: "time", label: "执行时间", displayType: "text-expression", defaultValue: "08:30", required: true },
{ key: "timezone", label: "时区", displayType: "text-expression", defaultValue: "Asia/Shanghai", required: true }
]
},
{
id: "Trigger.FileArrival", name: "文件到达触发", description: "监听指定目录的新文件事件。",
fields: [
{ key: "folder", label: "监听目录", displayType: "selectFile", defaultValue: "D:\\业务文件\\待处理", required: true, widget: "folder" },
{ key: "pattern", label: "文件匹配", displayType: "text-expression", defaultValue: "*.xlsx", required: true },
{ key: "stableSeconds", label: "文件稳定等待", displayType: "number", defaultValue: "3", required: true, unit: "秒" }
]
}
];
function cleanDefault(value) {
if (value === null || value === undefined) return "";
if (typeof value === "object") return "";
const text = String(value);
if (text === "None" || text === "null") return "";
if (/^"(?:[^"\\]|\\.)*"$/.test(text)) {
try { return JSON.parse(text); } catch { return text; }
}
return text;
}
function widgetFor(displayType, hint) {
if (hint) return hint;
const t = String(displayType || "");
if (/^hidden$/i.test(t)) return "hidden";
if (/excelRange|chooseRegion|captureWindow|openWindowSelect|mouseRecord|screenShot|getScreenShot|screenshotSelectUi|clickPosition|collectData|drawDataTable|makeOcrTableSelector|cellRule|dateRange|searchRange/i.test(t)) return "region";
if (/^code|jsonEditor|codeInput/i.test(t)) return "code";
if (/encryption|designPassword/i.test(t)) return "secret";
if (/comboSelector|selectUi|uiwindowselector|phoneSelector|verifyElement|newVerifyElement|cvselectUi|uiselectorOcr/i.test(t)) return "element";
if (/selectFile|multiSelectFileOrDir|saveAsDialog|selectControllerFile/i.test(t)) return "file";
if (/selectControllerFolder/i.test(t)) return "folder";
if (/textArea|multipleTextArea|quillInput|logInput|multiInput|inputItemArray|inputCompose/i.test(t)) return "textarea";
if (/checkbox|switchControl|multifileSwitch|placementCheckbox|introViewCheckbox|multifile$/i.test(t)) return "boolean";
if (/dropDownSelect|dynamicSelect|browserTypeInput|databaseType|autoComplete|phoneSelector|remoteOptionSelect/i.test(t)) return "select";
if (/radioGroup|radioLeft|radio$|buttonGroup|coloredRadio|radioInputNumber/i.test(t)) return "radio";
if (/number|sliderBar|rangeInput|unitInput|inputInterval|movingSpeed|touchCoord|pageScroll|windowSize/i.test(t)) return "number";
return "text";
}
function fieldTypeFor(widget) {
if (widget === "number") return "number";
if (widget === "boolean") return "boolean";
if (widget === "region" || widget === "element" || widget === "file" || widget === "folder") return "object";
return "string";
}
function packageName(component) {
return String(component.baseType || component.id).split("_")[0];
}
function groupFor(component) {
const pkg = packageName(component);
for (const [group, packages] of Object.entries(PACKAGE_GROUPS)) if (packages.has(pkg)) return group;
const cats = new Set(component.categories || []);
if ([...cats].some((x) => /web|browser|sap/i.test(x))) return "web";
if ([...cats].some((x) => /excel|word|pdf/i.test(x))) return "office";
if ([...cats].some((x) => /ocr|image|vision/i.test(x))) return "vision";
if ([...cats].some((x) => /mobile|peripheral/i.test(x))) return "mobile";
if ([...cats].some((x) => /file|ftp/i.test(x))) return "file";
if ([...cats].some((x) => /mail|network|notification|nlp/i.test(x))) return "message";
if ([...cats].some((x) => /window|cursor|input|controller|element/i.test(x))) return "desktop";
return "data";
}
function normalizeVendorField(field) {
const widget = widgetFor(field.displayType, field.widget);
const valueTypes = Array.isArray(field.valueTypes) ? [...field.valueTypes] : [];
const result = {
key: field.key,
label: field.label || field.key,
widget,
type: fieldTypeFor(widget),
displayType: field.displayType || "",
required: Boolean(field.required),
defaultValue: cleanDefault(field.defaultValue),
placeholder: field.placeholder || "",
description: field.description || "",
advanced: Boolean(field.advanced),
valueTypes
};
if (Array.isArray(field.options) && field.options.length) result.options = field.options;
if (field.unit) result.unit = field.unit;
return result;
}
function loadCatalogSource() {
const raw = fs.readFileSync(SRC_CATALOG, "utf8");
const start = raw.indexOf("=");
if (start < 0) throw new Error("catalog source has no assignment");
let body = raw.slice(start + 1).trim();
if (body.endsWith(";")) body = body.slice(0, -1);
return JSON.parse(body);
}
function loadComponentsNew() {
if (!fs.existsSync(SRC_COMPONENTS_NEW)) return new Map();
const list = JSON.parse(fs.readFileSync(SRC_COMPONENTS_NEW, "utf8"));
const map = new Map();
for (const item of list) map.set(item.type || item.baseType || item.id, item);
return map;
}
function enrichFromVendor(field, vendorComponent) {
if (!vendorComponent) return field;
const args = vendorComponent.definition?.args0 || [];
const arg = args.find((a) => a.name === field.key);
if (!arg) return field;
const out = { ...field };
if (!out.label && arg.label) out.label = arg.label;
if (!out.description && arg.tip) out.description = arg.tip;
if (arg.displayParam?.text && !out.label) out.label = arg.displayParam.text;
if (arg.displayType && !out.displayType) out.displayType = arg.displayType;
if (arg.isAdvanced && !out.advanced) out.advanced = true;
const vTypes = Array.isArray(arg.vType) ? arg.vType.flatMap((v) => v.types || []) : [];
if (vTypes.length) out.valueTypes = [...new Set([...out.valueTypes, ...vTypes])];
const rules = arg.validatorRules?.compAttrValue;
if (Array.isArray(rules) && rules.some((r) => Number(r.required) === 1)) out.required = true;
if (Array.isArray(arg.displayParam?.properties) && arg.displayParam.properties.includes("openDirectory")) out.widget = "folder";
return out;
}
function outputOf(component, vendorComponent) {
const first = component.outputFields?.[0];
const name = cleanDefault(first?.defaultValue);
const typeNames = first?.valueTypes || [];
return {
label: first?.label || "",
key: first?.key || "",
suggestedName: /^[A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*$/.test(name) ? name : "",
valueTypes: typeNames,
fields: (component.outputFields || []).map((f) => ({
key: f.key, label: f.label || f.key, defaultValue: cleanDefault(f.defaultValue), valueTypes: f.valueTypes || []
}))
};
}
function normalizeVendorComponent(component, vendorMap) {
const group = groupFor(component);
const vendor = vendorMap.get(component.id) || vendorMap.get(component.baseType);
const fields = (component.inputFields || []).map((f) => enrichFromVendor(normalizeVendorField(f), vendor));
const output = outputOf(component, vendor);
const risk = ["web", "app", "desktop", "message", "mobile"].includes(group) ? "high" : "low";
return {
id: component.id,
name: component.name,
kind: "vendor",
group,
categories: component.categories || [],
categorySignature: component.categorySignature || "",
description: component.description || component.messageTemplate || "",
messageTemplate: component.messageTemplate || "",
baseType: component.baseType || component.id,
functionName: component.functions?.[0]?.function || "",
functionCalls: (component.functions || []).map((f) => ({ function: f.function, args: f.arguments || [], assignTo: f.assignTo || "" })),
version: component.version || "",
tags: component.tags || [],
platformTag: component.platformTag || null,
icon: group,
risk,
fields,
output,
slots: []
};
}
function normalizeNativeNode(node) {
return {
id: node.id,
name: node.name,
kind: "native",
group: "control",
categories: node.categories || [],
categorySignature: (node.categories || []).join("/"),
description: node.description || "",
messageTemplate: "",
baseType: node.id,
functionName: node.id,
functionCalls: [],
version: "",
tags: [...(node.categories || []), node.kind || ""],
platformTag: null,
icon: "branch",
risk: node.id.includes("ai_vision") ? "high" : "low",
fields: (node.inputFields || []).map((key) => {
const booleanField = ["reverse", "is_async", "is_exits", "log_process"].includes(key);
return {
key,
label: CONTROL_LABELS[key] || key,
widget: booleanField ? "boolean" : "text",
type: booleanField ? "boolean" : "string",
displayType: booleanField ? "checkbox" : "text-expression",
required: key !== "log_process",
defaultValue: "",
placeholder: "",
description: "",
advanced: false,
valueTypes: []
};
}),
output: { label: "", key: "", suggestedName: node.outputFields?.[0] || "", valueTypes: [], fields: [] },
slots: node.childSlots || [],
nodeKind: node.kind || "statement"
};
}
function normalizeTrigger(trigger) {
return {
id: trigger.id,
name: trigger.name,
kind: "trigger",
group: "trigger",
categories: ["trigger"],
categorySignature: "trigger",
description: trigger.description,
messageTemplate: "",
baseType: "Trigger",
functionName: trigger.id,
functionCalls: [],
version: "",
tags: ["触发", trigger.name],
platformTag: null,
icon: "trigger",
risk: "low",
fields: trigger.fields.map((f) => normalizeVendorField(f)),
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: []
};
}
function main() {
const source = loadCatalogSource();
const vendorMap = loadComponentsNew();
const vendorComponents = (source.components || []).map((c) => normalizeVendorComponent(c, vendorMap));
const nativeNodes = (source.nativeControlNodes || []).map(normalizeNativeNode);
const triggers = TRIGGERS.map(normalizeTrigger);
const components = [...triggers, ...nativeNodes, ...vendorComponents];
const groupMap = new Map(GROUPS.map((g) => [g.id, { ...g, count: 0 }]));
for (const c of components) {
const g = groupMap.get(c.group) || groupMap.get("data");
g.count += 1;
}
const counts = {
total: components.length,
vendor: vendorComponents.length,
native: nativeNodes.length,
triggers: triggers.length,
fields: components.reduce((sum, c) => sum + c.fields.length, 0)
};
const catalog = {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
source: {
catalog: path.basename(SRC_CATALOG),
componentsNew: fs.existsSync(SRC_COMPONENTS_NEW) ? path.basename(SRC_COMPONENTS_NEW) : null
},
counts,
groups: [...groupMap.values()],
components
};
fs.mkdirSync(path.dirname(OUT_FILE), { recursive: true });
fs.writeFileSync(OUT_FILE, JSON.stringify(catalog), "utf8");
const size = fs.statSync(OUT_FILE).size;
console.log(`catalog: ${counts.total} components (${counts.vendor} vendor + ${counts.native} native + ${counts.triggers} triggers)`);
console.log(`fields: ${counts.fields}; groups: ${catalog.groups.map((g) => `${g.id}:${g.count}`).join(",")}`);
console.log(`output: ${OUT_FILE} (${(size / 1024 / 1024).toFixed(2)} MB)`);
}
main();

61
scripts/cdp-probe.mjs Normal file
View File

@ -0,0 +1,61 @@
/**
* 通过 CDP 读取运行中的 Electron 页面:文本、节点数量与控制台错误。
* 用法:node scripts/cdp-probe.mjs [expression]
*/
const endpoint = process.env.CDP_ENDPOINT || "http://127.0.0.1:9223";
const targets = await (await fetch(`${endpoint}/json/list`)).json();
const page = targets.find((item) => item.type === "page");
if (!page) throw new Error("no page target");
const ws = new WebSocket(page.webSocketDebuggerUrl);
const pending = new Map();
const events = [];
let seq = 0;
function send(method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++seq;
pending.set(id, { resolve, reject });
ws.send(JSON.stringify({ id, method, params }));
});
}
await new Promise((resolve, reject) => {
ws.onopen = resolve;
ws.onerror = reject;
});
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.id && pending.has(message.id)) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(new Error(JSON.stringify(message.error)));
else resolve(message.result);
} else {
events.push(message);
}
};
await send("Runtime.enable");
await send("Log.enable");
const expression = process.argv[2] || "document.body.innerText.slice(0, 6000)";
const result = await send("Runtime.evaluate", {
expression,
returnByValue: true,
awaitPromise: true
});
console.log("=== result ===");
console.log(JSON.stringify(result.result?.value ?? result, null, 2));
const errors = events.filter(
(item) =>
item.method === "Runtime.exceptionThrown" ||
(item.method === "Log.entryAdded" && ["error", "warning"].includes(item.params?.entry?.level))
);
console.log("\n=== errors/warnings ===");
console.log(JSON.stringify(errors.map((item) => item.params), null, 2).slice(0, 6000));
ws.close();

29
scripts/dev-safe.mjs Normal file
View File

@ -0,0 +1,29 @@
/**
* 本地开发启动器。
*
* Electron 在本工作区(D:\RPA)下会因工作区 ACL/沙箱限制无法直接启动,
* 因此允许通过 RPA_ELECTRON_DIST 指向一个可用的 Electron 运行时目录。
* 例如:set RPA_ELECTRON_DIST=C:\electron-rpa\dist
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const candidates = [
process.env.RPA_ELECTRON_DIST,
"C:/electron-rpa/dist",
path.join(process.cwd(), "node_modules", "electron", "dist")
].filter(Boolean);
const dist = candidates.find((candidate) => fs.existsSync(path.join(candidate, "electron.exe")));
if (!dist) {
console.error("找不到可用的 Electron 运行时。请设置 RPA_ELECTRON_DIST 指向包含 electron.exe 的目录。");
process.exit(1);
}
const env = { ...process.env, ELECTRON_OVERRIDE_DIST_PATH: dist };
delete env.ELECTRON_RUN_AS_NODE;
console.log(`[dev] electron dist: ${dist}`);
const child = spawn("npx.cmd", ["electron-vite", "dev"], { stdio: "inherit", env, shell: true });
child.on("exit", (code) => process.exit(code ?? 0));

27
scripts/start.mjs Normal file
View File

@ -0,0 +1,27 @@
/**
* 启动已构建的应用(out/ 已存在)。
* 用法:node scripts/start.mjs [--no-sandbox] [--remote-debugging-port=9223]
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const candidates = [
process.env.RPA_ELECTRON_DIST,
"C:/electron-rpa/dist",
path.join(process.cwd(), "node_modules", "electron", "dist")
].filter(Boolean);
const dist = candidates.find((candidate) => fs.existsSync(path.join(candidate, "electron.exe")));
if (!dist) {
console.error("找不到可用的 Electron 运行时。请设置 RPA_ELECTRON_DIST。");
process.exit(1);
}
const env = { ...process.env };
delete env.ELECTRON_RUN_AS_NODE;
const args = [path.resolve("."), ...process.argv.slice(2)];
console.log(`[start] ${path.join(dist, "electron.exe")} ${args.join(" ")}`);
const child = spawn(path.join(dist, "electron.exe"), args, { stdio: "inherit", env });
child.on("exit", (code) => process.exit(code ?? 0));

62
src/main/catalog.ts Normal file
View File

@ -0,0 +1,62 @@
import { app } from "electron";
import fs from "node:fs";
import path from "node:path";
import type { Catalog, CatalogComponent } from "../shared/types";
let cached: Catalog | null = null;
function resolveCatalogPath(): string {
const candidates = [
process.env.RPA_CATALOG_PATH,
path.join(process.resourcesPath, "catalog.json"),
path.join(app.getAppPath(), "resources", "catalog.json"),
path.join(__dirname, "../../resources/catalog.json")
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error(`catalog.json not found; tried: ${candidates.join(", ")}`);
}
/**
* 已注册执行器的函数名。Day 2 起由 capabilities 注册表汇总;
* Day 1 先标出控制/触发与少量基础组件,保证目录状态可演示。
*/
const EXECUTABLE_FUNCTIONS = new Set<string>([
"Trigger.Manual",
"Trigger.Schedule",
"Trigger.FileArrival",
"logic_control_if_else",
"logic_control_count_for",
"logic_control_array_for_v3",
"logic_control_while",
"logic_control_try_catch",
"logic_control_var",
"logic_control_break_for",
"logic_control_next_for",
"logic_control_stop",
"Basic_Delay_V2",
"Basic_SetVariable",
"Basic_DebugOutput"
]);
export function catalogPath(): string {
return resolveCatalogPath();
}
export function loadCatalog(): Catalog {
if (cached) return cached;
const raw = fs.readFileSync(catalogPath(), "utf8");
const catalog = JSON.parse(raw) as Catalog;
catalog.components = catalog.components.map((component: CatalogComponent) => ({
...component,
implemented: EXECUTABLE_FUNCTIONS.has(component.functionName) || EXECUTABLE_FUNCTIONS.has(component.id)
}));
cached = catalog;
return catalog;
}
export function markImplemented(functionNames: Iterable<string>): void {
for (const name of functionNames) EXECUTABLE_FUNCTIONS.add(name);
cached = null;
}

95
src/main/flows.ts Normal file
View File

@ -0,0 +1,95 @@
import { app } from "electron";
import fs from "node:fs";
import path from "node:path";
import type { FlowDefinition, FlowSummary } from "../shared/types";
const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/;
function dir(): string {
const value = path.join(app.getPath("userData"), "flows");
fs.mkdirSync(value, { recursive: true });
return value;
}
function fileOf(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error(`invalid flow id: ${id}`);
return path.join(dir(), `${id}.json`);
}
export function createFlow(name = "未命名流程"): FlowDefinition {
const now = new Date().toISOString();
const id = `flow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
return {
schemaVersion: 1,
id,
name,
description: "",
variables: [],
nodes: [
{
id: "node_start",
componentId: "Trigger.Manual",
position: { x: 160, y: 120 },
values: { entryName: "立即执行" },
options: { timeoutMs: 30000 }
}
],
edges: [],
meta: { createdAt: now, updatedAt: now, appVersion: app.getVersion() }
};
}
export function listFlows(): FlowSummary[] {
const base = dir();
const rows: FlowSummary[] = [];
for (const entry of fs.readdirSync(base)) {
if (!entry.endsWith(".json")) continue;
try {
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
rows.push({
id: flow.id,
name: flow.name || "未命名流程",
updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(),
nodeCount: flow.nodes.length
});
} catch {
// 忽略损坏文件,避免一个坏流程阻塞列表
}
}
return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function getFlow(id: string): FlowDefinition {
const raw = fs.readFileSync(fileOf(id), "utf8");
const flow = JSON.parse(raw) as FlowDefinition;
if (!flow || flow.schemaVersion !== 1 || !Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) {
throw new Error(`invalid flow file: ${id}`);
}
return flow;
}
export function saveFlow(input: FlowDefinition): FlowDefinition {
if (!input || input.schemaVersion !== 1) throw new Error("invalid flow schemaVersion");
if (!Array.isArray(input.nodes) || !Array.isArray(input.edges)) throw new Error("invalid flow structure");
if (input.nodes.length > 2000) throw new Error("flow has too many nodes");
const now = new Date().toISOString();
const flow: FlowDefinition = {
...input,
meta: {
createdAt: input.meta?.createdAt || now,
updatedAt: now,
appVersion: app.getVersion()
}
};
const target = fileOf(flow.id);
const temp = `${target}.tmp`;
fs.writeFileSync(temp, JSON.stringify(flow, null, 2), "utf8");
fs.renameSync(temp, target);
return flow;
}
export function deleteFlow(id: string): void {
const target = fileOf(id);
if (fs.existsSync(target)) fs.unlinkSync(target);
}

115
src/main/index.ts Normal file
View File

@ -0,0 +1,115 @@
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
import path from "node:path";
import type { AppInfo, FlowDefinition } from "../shared/types";
import { catalogPath, loadCatalog } from "./catalog";
import { createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
let mainWindow: BrowserWindow | null = null;
function createWindow(): void {
mainWindow = new BrowserWindow({
width: 1440,
height: 900,
minWidth: 1200,
minHeight: 760,
show: false,
backgroundColor: "#E7EDF3",
title: "RPA Studio",
titleBarStyle: "hidden",
titleBarOverlay: {
color: "#FFFFFF",
symbolColor: "#4E5A6B",
height: 58
},
webPreferences: {
preload: path.join(__dirname, "../preload/index.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false
}
});
mainWindow.on("ready-to-show", () => mainWindow?.show());
mainWindow.on("closed", () => {
mainWindow = null;
});
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
if (/^https?:/i.test(url)) void shell.openExternal(url);
return { action: "deny" };
});
const devUrl = process.env.ELECTRON_RENDERER_URL;
if (devUrl) {
void mainWindow.loadURL(devUrl);
} else {
void mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
}
}
function registerIpc(): void {
ipcMain.handle("app:info", (): AppInfo => ({
version: app.getVersion(),
electron: process.versions.electron,
chrome: process.versions.chrome,
node: process.versions.node,
userData: app.getPath("userData"),
catalogPath: catalogPath(),
packaged: app.isPackaged
}));
ipcMain.handle("catalog:get", () => loadCatalog());
ipcMain.handle("flows:list", () => listFlows());
ipcMain.handle("flows:create", (_event, name?: string) => {
const flow = createFlow(name);
return saveFlow(flow);
});
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
ipcMain.handle("flows:save", (_event, flow: FlowDefinition) => saveFlow(flow));
ipcMain.handle("flows:delete", (_event, id: string) => {
deleteFlow(id);
return true;
});
ipcMain.handle("dialog:open", async (_event, options: { title?: string; filters?: Electron.FileFilter[]; directories?: boolean }) => {
if (!mainWindow) return null;
const result = await dialog.showOpenDialog(mainWindow, {
title: options?.title || "选择文件",
properties: options?.directories ? ["openDirectory"] : ["openFile"],
filters: options?.filters
});
return result.canceled ? null : result.filePaths[0] || null;
});
ipcMain.handle("dialog:save", async (_event, options: { title?: string; defaultPath?: string; filters?: Electron.FileFilter[] }) => {
if (!mainWindow) return null;
const result = await dialog.showSaveDialog(mainWindow, {
title: options?.title || "保存文件",
defaultPath: options?.defaultPath,
filters: options?.filters
});
return result.canceled ? null : result.filePath || null;
});
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
if (!mainWindow) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
});
app.whenReady().then(() => {
registerIpc();
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow();
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
}

28
src/preload/index.ts Normal file
View File

@ -0,0 +1,28 @@
import { contextBridge, ipcRenderer } from "electron";
import type { AppInfo, Catalog, FlowDefinition, FlowSummary } from "../shared/types";
const api = {
app: {
info: (): Promise<AppInfo> => ipcRenderer.invoke("app:info")
},
catalog: {
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
},
flows: {
list: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:list"),
create: (name?: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create", name),
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
save: (flow: FlowDefinition): Promise<FlowDefinition> => ipcRenderer.invoke("flows:save", flow),
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
},
dialog: {
open: (options?: { title?: string; filters?: Array<{ name: string; extensions: string[] }>; directories?: boolean }): Promise<string | null> =>
ipcRenderer.invoke("dialog:open", options || {}),
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
ipcRenderer.invoke("dialog:save", options || {})
}
};
contextBridge.exposeInMainWorld("rpa", api);
export type RpaApi = typeof api;

13
src/renderer/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:;" />
<title>RPA Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

156
src/renderer/src/App.tsx Normal file
View File

@ -0,0 +1,156 @@
import { useCallback, useEffect, useState } from "react";
import { Bot, CircleAlert, Loader2, Sparkles } from "lucide-react";
import type { Catalog } from "@shared/types";
import { useEditorStore } from "./store/editorStore";
import { EditorPage } from "./pages/EditorPage";
import { RunsPage } from "./pages/RunsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { railIcons } from "./components/icons";
type Page = "editor" | "runs" | "settings";
interface ToastState {
id: number;
message: string;
kind: "ok" | "error";
}
const PAGE_LABEL: Record<Page, string> = {
editor: "流程编排",
runs: "运行中心",
settings: "设置"
};
export function App() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [page, setPage] = useState<Page>("editor");
const [toast, setToast] = useState<ToastState | null>(null);
const [error, setError] = useState<string | null>(null);
const setCatalogStore = useEditorStore((state) => state.setCatalog);
const loadFlow = useEditorStore((state) => state.loadFlow);
const showToast = useCallback((message: string, kind: "ok" | "error" = "ok") => {
setToast({ id: Date.now(), message, kind });
}, []);
useEffect(() => {
if (!toast) return;
const timer = window.setTimeout(() => setToast(null), 3200);
return () => window.clearTimeout(timer);
}, [toast]);
useEffect(() => {
let cancelled = false;
const boot = async () => {
try {
const loaded = await window.rpa.catalog.get();
if (cancelled) return;
setCatalogStore(loaded);
setCatalog(loaded);
const flows = await window.rpa.flows.list();
const flow = flows.length ? await window.rpa.flows.get(flows[0].id) : await window.rpa.flows.create("网页订单录入");
if (!cancelled) loadFlow(flow);
} catch (bootError) {
if (!cancelled) setError(bootError instanceof Error ? bootError.message : String(bootError));
}
};
void boot();
return () => {
cancelled = true;
};
}, [loadFlow, setCatalogStore]);
if (error) {
return (
<div className="loading" style={{ padding: 40 }}>
<CircleAlert className="icon" style={{ color: "var(--danger)", width: 28, height: 28 }} />
<strong style={{ color: "var(--ink)" }}>启动失败</strong>
<span style={{ maxWidth: 520, textAlign: "center", lineHeight: "18px" }}>{error}</span>
</div>
);
}
if (!catalog) {
return (
<div className="loading">
<Loader2 className="icon spin" style={{ width: 24, height: 24 }} />
正在加载 1016 项组件目录…
</div>
);
}
const executable = catalog.components.filter((item) => item.implemented).length;
return (
<div className="app">
<header className="titlebar">
<div className="brand">
<span className="logo">
<Bot className="icon" />
</span>
<strong>RPA Studio</strong>
</div>
<span className="titlebar-divider" />
<span className="workspace-chip">
{page === "editor" ? <Sparkles className="icon" /> : page === "runs" ? <railIcons.runs className="icon" /> : <railIcons.settings className="icon" />}
{PAGE_LABEL[page]}
</span>
<div className="titlebar-right">
<span className="engine-chip" title="运行期完全离线">
<span className="engine-dot" />
本地执行 · 离线
</span>
<span className="engine-chip">
{catalog.counts.total} 目录 · {executable} 可执行
</span>
</div>
</header>
<nav className="rail" aria-label="主导航">
<button
type="button"
className={`rail-item${page === "editor" ? " active" : ""}`}
onClick={() => setPage("editor")}
>
<railIcons.editor className="icon" />
流程
</button>
<button
type="button"
className={`rail-item${page === "runs" ? " active" : ""}`}
onClick={() => setPage("runs")}
>
<railIcons.runs className="icon" />
运行
</button>
<button
type="button"
className={`rail-item${page === "settings" ? " active" : ""}`}
onClick={() => setPage("settings")}
>
<railIcons.settings className="icon" />
设置
</button>
<span className="rail-spacer" />
<div className="rail-user">
<span className="rail-avatar">RA</span>
<span className="rail-name">本机用户</span>
<span className="rail-role">离线工作区</span>
</div>
</nav>
<main className="main">
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} />}
{page === "runs" && <RunsPage onBack={() => setPage("editor")} />}
{page === "settings" && <SettingsPage catalog={catalog} />}
</main>
{toast && (
<div className={`toast${toast.kind === "error" ? " error" : ""}`}>
{toast.kind === "error" ? <CircleAlert className="icon" /> : <Bot className="icon" />}
{toast.message}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,187 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import { ArrowDown, ArrowUp, Boxes, Plus, Trash2 } from "lucide-react";
import type { CatalogComponent, FlowNode } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { groupIcon } from "../components/icons";
export interface ContainerSlot {
key: string;
label: string;
hint: string;
}
export interface ContainerNodeData extends Record<string, unknown> {
component: CatalogComponent;
node: FlowNode;
slots: ContainerSlot[];
childrenBySlot: Record<string, FlowNode[]>;
components: Map<string, CatalogComponent>;
selectedChildId?: string | null;
}
export function containerSlots(component: CatalogComponent): ContainerSlot[] {
switch (component.id) {
case "logic_control_if_else":
return [
{ key: "statement_if", label: "条件为真", hint: "拖入真分支步骤" },
{ key: "statement_else", label: "条件为假", hint: "拖入假分支步骤" }
];
case "logic_control_count_for":
return [{ key: "count_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
case "logic_control_array_for_v3":
return [{ key: "array_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
case "logic_control_dictionary_for_v1":
return [{ key: "dictionary_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
case "logic_control_while":
return [{ key: "if_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
case "infinite_loop":
return [{ key: "loop_body", label: "循环体", hint: "拖入循环体步骤" }];
case "logic_control_try_catch":
return [
{ key: "try_fn", label: "Try", hint: "拖入受保护步骤" },
{ key: "except_fn", label: "Catch", hint: "拖入异常处理步骤" },
{ key: "else_fn", label: "Else", hint: "拖入无异常步骤" }
];
case "Element_exception_monitor_v2":
return [{ key: "exception_monitor_params", label: "监控体", hint: "拖入监控步骤" }];
default: {
const key = component.slots?.[0] || "body";
return [{ key, label: "步骤", hint: "拖入步骤" }];
}
}
}
export function ContainerNode({ data, selected }: NodeProps) {
const nodeData = data as ContainerNodeData;
const { component, node, slots, childrenBySlot, components, selectedChildId } = nodeData;
const addChild = useEditorStore((state) => state.addChild);
const selectNode = useEditorStore((state) => state.selectNode);
const removeNode = useEditorStore((state) => state.removeNode);
const moveChild = useEditorStore((state) => state.moveChild);
const Icon = groupIcon(component.group);
const pending = !component.implemented;
return (
<div
className={[
"container-node",
`tone-${component.group}`,
selected ? "selected" : "",
pending ? "pending" : ""
]
.filter(Boolean)
.join(" ")}
onClick={() => selectNode(node.id)}
>
<Handle type="target" position={Position.Left} />
<div className="container-head">
<span className="container-icon">
<Icon className="icon" />
</span>
<span className="container-copy">
<span className="container-kicker">容器 · {component.id}</span>
<strong>{node.label || component.name}</strong>
</span>
{pending && <span className="pending-badge">待接入</span>}
</div>
<div className="container-body">
{slots.map((slot) => {
const children = childrenBySlot[slot.key] || [];
return (
<section className="container-slot" key={slot.key}>
<div className="container-slot-head">
<span>{slot.label}</span>
<span className="container-slot-count">{children.length}</span>
</div>
{children.map((child, index) => {
const childComponent = components.get(child.componentId);
const ChildIcon = groupIcon(childComponent?.group || "data");
const childPending = childComponent ? !childComponent.implemented : true;
return (
<div
key={child.id}
className={`container-child${selectedChildId === child.id ? " selected" : ""}${
childPending ? " pending" : ""
}`}
onClick={(event) => {
event.stopPropagation();
selectNode(child.id);
}}
>
<span className="container-child-index">{index + 1}</span>
<span className="container-child-icon">
<ChildIcon className="icon" />
</span>
<span className="container-child-copy">
<span className="container-child-name">{child.label || childComponent?.name || child.componentId}</span>
<span className="container-child-id">{child.componentId}</span>
</span>
{child.options?.outputVar && <span className="container-child-var">{child.options.outputVar}</span>}
<span className="container-child-actions">
<button
type="button"
className="icon-btn"
title="上移"
onClick={(event) => {
event.stopPropagation();
moveChild(child.id, -1);
}}
>
<ArrowUp className="icon" />
</button>
<button
type="button"
className="icon-btn"
title="下移"
onClick={(event) => {
event.stopPropagation();
moveChild(child.id, 1);
}}
>
<ArrowDown className="icon" />
</button>
<button
type="button"
className="icon-btn"
title="删除"
onClick={(event) => {
event.stopPropagation();
removeNode(child.id);
}}
>
<Trash2 className="icon" />
</button>
</span>
</div>
);
})}
<div
className="container-drop nodrag"
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={(event) => {
event.preventDefault();
event.stopPropagation();
event.stopPropagation();
const componentId = event.dataTransfer.getData("application/rpa-component");
if (componentId) addChild(node.id, slot.key, componentId);
}}
>
<Plus className="icon" />
{slot.hint}
</div>
</section>
);
})}
</div>
<Handle type="source" position={Position.Right} />
</div>
);
}

View File

@ -0,0 +1,259 @@
import { useCallback, useEffect, useMemo } from "react";
import {
Background,
BackgroundVariant,
Controls,
MarkerType,
MiniMap,
ReactFlow,
ReactFlowProvider,
useReactFlow,
type Connection,
type Edge,
type EdgeChange,
type Node,
type NodeChange
} from "@xyflow/react";
import type { Catalog, CatalogComponent } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { RpaNode, type RpaNodeData } from "./RpaNode";
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
import { MousePointerClick } from "lucide-react";
const nodeTypes = { rpa: RpaNode, container: ContainerNode };
const GROUP_COLORS: Record<string, string> = {
control: "#4b5bd6",
trigger: "#b45309",
app: "#2264e5",
web: "#0e7b6b",
office: "#b56a08",
data: "#2264e5",
file: "#52627a",
desktop: "#6b46c1",
vision: "#3f5fd0",
message: "#0e7b6b",
mobile: "#7a4bc1"
};
function subtitleFor(component: Catalog["components"][number], values: Record<string, unknown>): string {
const field = component.fields.find((item) => item.widget !== "hidden" && item.widget !== "region");
if (field) {
const raw = values[field.key];
const text = raw === undefined || raw === null || raw === "" ? "" : String(raw);
if (text) return `${field.label}:${text.length > 28 ? `${text.slice(0, 28)}…` : text}`;
}
return component.description || component.id;
}
function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (componentId: string, position: { x: number; y: number }) => void }) {
const flow = useEditorStore((state) => state.flow);
const selectedNodeId = useEditorStore((state) => state.selectedNodeId);
const selectNode = useEditorStore((state) => state.selectNode);
const moveNode = useEditorStore((state) => state.moveNode);
const removeNode = useEditorStore((state) => state.removeNode);
const addEdge = useEditorStore((state) => state.addEdge);
const removeEdge = useEditorStore((state) => state.removeEdge);
const { screenToFlowPosition, fitView } = useReactFlow();
const componentMap = useMemo(() => new Map(catalog.components.map((item) => [item.id, item])), [catalog]);
const nodes = useMemo<Node[]>(() => {
if (!flow) return [];
return flow.nodes
.filter((node) => !node.parentId)
.map((node) => {
const component = componentMap.get(node.componentId);
if (component && component.slots.length > 0) {
const slots = containerSlots(component);
const childrenBySlot: Record<string, typeof flow.nodes> = {};
let rowCount = 0;
for (const slot of slots) {
childrenBySlot[slot.key] = flow.nodes.filter(
(item) => item.parentId === node.id && item.slot === slot.key
);
rowCount += childrenBySlot[slot.key].length;
}
const height = 58 + slots.length * 26 + rowCount * 46 + slots.length * 35 + 16;
const data: ContainerNodeData = {
component,
node,
slots,
childrenBySlot,
components: componentMap,
selectedChildId: selectedNodeId
};
return {
id: node.id,
type: "container",
position: node.position,
selected: node.id === selectedNodeId,
width: 306,
height,
style: { width: 306, height },
data: data as unknown as Record<string, unknown>
} as Node;
}
const fallback: CatalogComponent = {
id: node.componentId,
name: node.componentId,
kind: "vendor",
group: "data",
categories: [],
categorySignature: "",
description: "",
messageTemplate: "",
baseType: node.componentId,
functionName: "",
functionCalls: [],
version: "",
tags: [],
platformTag: null,
icon: "data",
risk: "low",
fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [],
implemented: false
};
return {
id: node.id,
type: "rpa",
position: node.position,
selected: node.id === selectedNodeId,
width: 258,
height: 82,
data: {
component: component || fallback,
label: node.label,
subtitle: component ? subtitleFor(component, node.values) : undefined,
outputVar: node.options?.outputVar
} as RpaNodeData as unknown as Record<string, unknown>
} as Node;
});
}, [flow, componentMap, selectedNodeId]);
const edges = useMemo<Edge[]>(() => {
if (!flow) return [];
const topLevel = new Set(flow.nodes.filter((node) => !node.parentId).map((node) => node.id));
return flow.edges
.filter((edge) => topLevel.has(edge.source) && topLevel.has(edge.target))
.map((edge) => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || undefined,
targetHandle: edge.targetHandle || undefined,
type: "smoothstep",
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: "#b8c5d6" }
}));
}, [flow]);
const handleNodesChange = useCallback(
(changes: NodeChange[]) => {
for (const change of changes) {
if (change.type === "position" && change.position) {
moveNode(change.id, change.position);
} else if (change.type === "remove") {
removeNode(change.id);
} else if (change.type === "select") {
if (change.selected) selectNode(change.id);
else if (selectedNodeId === change.id) selectNode(null);
}
}
},
[moveNode, removeNode, selectNode, selectedNodeId]
);
const handleEdgesChange = useCallback(
(changes: EdgeChange[]) => {
for (const change of changes) if (change.type === "remove") removeEdge(change.id);
},
[removeEdge]
);
const handleConnect = useCallback(
(connection: Connection) => {
addEdge({
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle
});
},
[addEdge]
);
useEffect(() => {
if (!flow) return;
const timer = window.setTimeout(() => fitView({ padding: 0.3, duration: 240 }), 80);
return () => window.clearTimeout(timer);
}, [flow?.id, fitView]);
if (!flow) return null;
return (
<div
className="flow-canvas"
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={(event) => {
event.preventDefault();
const componentId = event.dataTransfer.getData("application/rpa-component");
if (!componentId) return;
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
onAddAt(componentId, position);
}}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
onConnect={handleConnect}
onPaneClick={() => selectNode(null)}
onNodeClick={(_event, node) => selectNode(node.id)}
defaultEdgeOptions={{ type: "smoothstep" }}
deleteKeyCode={["Delete", "Backspace"]}
minZoom={0.3}
maxZoom={2}
fitView
proOptions={{ hideAttribution: true }}
snapToGrid
snapGrid={[10, 10]}
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1.2} color="rgba(138,153,172,.34)" />
<Controls position="bottom-right" showInteractive={false} />
<MiniMap
position="bottom-right"
pannable
zoomable
style={{ width: 132, height: 92, marginBottom: 44 }}
nodeColor={(node) => GROUP_COLORS[(node.data as RpaNodeData)?.component?.group || "data"] || "#2264e5"}
maskColor="rgba(242,245,248,.72)"
/>
</ReactFlow>
{flow.nodes.length <= 1 && (
<div className="canvas-hint">
<MousePointerClick className="icon" />
<div>
<strong>从左侧拖入能力,开始搭建流程</strong>
<span>支持搜索 1016 项目录;单击组件也可追加。</span>
</div>
</div>
)}
</div>
);
}
export function FlowCanvas(props: { catalog: Catalog; onAddAt: (componentId: string, position: { x: number; y: number }) => void }) {
return (
<ReactFlowProvider>
<CanvasInner {...props} />
</ReactFlowProvider>
);
}

View File

@ -0,0 +1,47 @@
import { Handle, Position, type NodeProps } from "@xyflow/react";
import type { CatalogComponent } from "@shared/types";
import { groupIcon } from "../components/icons";
export interface RpaNodeData extends Record<string, unknown> {
component: CatalogComponent;
label?: string;
subtitle?: string;
outputVar?: string;
status?: "idle" | "running" | "succeeded" | "failed";
}
export function RpaNode({ data, selected }: NodeProps) {
const nodeData = data as RpaNodeData;
const component = nodeData.component;
const Icon = groupIcon(component.group);
const pending = !component.implemented;
return (
<div
className={[
"rpa-node",
`tone-${component.group}`,
selected ? "selected" : "",
pending ? "pending" : "",
nodeData.status && nodeData.status !== "idle" ? nodeData.status : ""
]
.filter(Boolean)
.join(" ")}
>
<Handle type="target" position={Position.Left} className="rpa-handle" />
<span className="rpa-node-icon">
<Icon className="icon" />
</span>
<span className="rpa-node-copy">
<span className="rpa-node-top">
<span className="rpa-node-kind">{component.kind === "native" ? "控制" : component.kind === "trigger" ? "触发" : component.group}</span>
{pending && <span className="pending-badge">待接入</span>}
{!pending && nodeData.outputVar && <span className="rpa-node-var">{nodeData.outputVar}</span>}
</span>
<span className="rpa-node-title">{nodeData.label || component.name}</span>
<span className="rpa-node-sub">{nodeData.subtitle || component.description || component.id}</span>
</span>
<Handle type="source" position={Position.Right} className="rpa-handle" />
</div>
);
}

View File

@ -0,0 +1,51 @@
import {
AppWindow,
Bot,
Boxes,
CirclePlay,
Database,
FileText,
GitBranch,
Globe2,
History,
Layers,
MousePointer2,
ScanLine,
Send,
Settings,
Smartphone,
Table2,
Workflow,
Zap
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
const GROUP_ICONS: Record<string, LucideIcon> = {
control: GitBranch,
trigger: Zap,
app: AppWindow,
web: Globe2,
office: Table2,
data: Database,
file: FileText,
desktop: MousePointer2,
vision: ScanLine,
message: Send,
mobile: Smartphone
};
export function groupIcon(group: string): LucideIcon {
return GROUP_ICONS[group] || Boxes;
}
export const railIcons = {
editor: Workflow,
runs: History,
settings: Settings
};
export const uiIcons = {
play: CirclePlay,
bot: Bot,
layers: Layers
};

9
src/renderer/src/env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
import type { RpaApi } from "../../preload/index";
declare global {
interface Window {
rpa: RpaApi;
}
}
export {};

12
src/renderer/src/main.tsx Normal file
View File

@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./App";
import "./styles/tokens.css";
import "./styles/app.css";
import "@xyflow/react/dist/style.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,155 @@
import { useCallback, useEffect, useState } from "react";
import { Check, CirclePlay, Loader2, Redo2, Save, Undo2 } from "lucide-react";
import type { Catalog } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { Palette } from "../panels/Palette";
import { Properties } from "../panels/Properties";
import { FlowCanvas } from "../canvas/FlowCanvas";
import { containerSlots } from "../canvas/ContainerNode";
interface EditorPageProps {
catalog: Catalog;
onToast: (message: string, kind?: "ok" | "error") => void;
}
export function EditorPage({ catalog, onToast }: EditorPageProps) {
const flow = useEditorStore((state) => state.flow);
const dirty = useEditorStore((state) => state.dirty);
const addNode = useEditorStore((state) => state.addNode);
const addChild = useEditorStore((state) => state.addChild);
const undo = useEditorStore((state) => state.undo);
const redo = useEditorStore((state) => state.redo);
const renameFlow = useEditorStore((state) => state.renameFlow);
const markSaved = useEditorStore((state) => state.markSaved);
const canUndo = useEditorStore((state) => state.past.length > 0);
const canRedo = useEditorStore((state) => state.future.length > 0);
const [saving, setSaving] = useState(false);
const [savedAt, setSavedAt] = useState<string | null>(null);
const save = useCallback(async () => {
const current = useEditorStore.getState().flow;
if (!current || saving) return;
setSaving(true);
try {
await window.rpa.flows.save(current);
markSaved();
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
} catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error");
} finally {
setSaving(false);
}
}, [markSaved, onToast, saving]);
useEffect(() => {
if (!dirty) return;
const timer = window.setTimeout(() => void save(), 900);
return () => window.clearTimeout(timer);
}, [dirty, flow, save]);
useEffect(() => {
const handler = (event: KeyboardEvent) => {
const mod = event.ctrlKey || event.metaKey;
if (!mod) return;
if (event.key.toLowerCase() === "s") {
event.preventDefault();
void save();
} else if (event.key.toLowerCase() === "z" && !event.shiftKey) {
event.preventDefault();
undo();
} else if ((event.key.toLowerCase() === "y" || (event.key.toLowerCase() === "z" && event.shiftKey))) {
event.preventDefault();
redo();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [redo, save, undo]);
if (!flow) {
return (
<div className="loading">
<Loader2 className="icon spin" />
正在加载流程…
</div>
);
}
const addAtEnd = (componentId: string) => {
const state = useEditorStore.getState();
const nodes = state.flow?.nodes || [];
const selected = nodes.find((node) => node.id === state.selectedNodeId);
if (selected?.parentId) {
addChild(selected.parentId, selected.slot || "body", componentId);
return;
}
const selectedComponent = selected ? catalog.components.find((item) => item.id === selected.componentId) : null;
if (selected && selectedComponent && selectedComponent.slots.length > 0) {
addChild(selected.id, containerSlots(selectedComponent)[0].key, componentId);
return;
}
const maxY = nodes.reduce((max, node) => Math.max(max, node.position.y), 80);
addNode(componentId, { x: 180, y: nodes.length <= 1 ? 240 : maxY + 116 });
};
return (
<div className="editor">
<Palette catalog={catalog} onAdd={addAtEnd} />
<section className="panel canvas-panel" aria-label="流程画布">
<div className="canvas-toolbar">
<div className="flow-meta">
<input
className="flow-name"
value={flow.name}
onChange={(event) => renameFlow(event.target.value)}
aria-label="流程名称"
/>
<div className="flow-sub">
<span>{flow.nodes.length} 个节点</span>
<span className="dot" />
<span>{flow.edges.length} 条连线</span>
<span className="dot" />
{saving ? (
<span>保存中…</span>
) : dirty ? (
<span className="dirty">有未保存修改</span>
) : (
<span>{savedAt ? `已保存 ${savedAt}` : "已保存"}</span>
)}
</div>
</div>
<div className="toolbar-actions">
<button type="button" className="icon-btn" title="撤销 (Ctrl+Z)" onClick={undo} disabled={!canUndo}>
<Undo2 className="icon" />
</button>
<button type="button" className="icon-btn" title="重做 (Ctrl+Y)" onClick={redo} disabled={!canRedo}>
<Redo2 className="icon" />
</button>
<button type="button" className="icon-btn" title="保存 (Ctrl+S)" onClick={() => void save()} disabled={saving}>
{saving ? <Loader2 className="icon spin" /> : <Save className="icon" />}
</button>
<button
type="button"
className="btn btn-primary"
disabled
title="Day 2 接入执行内核后开放"
>
<CirclePlay className="icon" /> 运行
</button>
</div>
</div>
<FlowCanvas catalog={catalog} onAddAt={(componentId, position) => addNode(componentId, position)} />
<div className="canvas-badge">
<Check className="icon" />
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项
</div>
</section>
<Properties catalog={catalog} onToast={onToast} />
</div>
);
}

View File

@ -0,0 +1,51 @@
import { History, Play, RotateCcw } from "lucide-react";
export function RunsPage({ onBack }: { onBack: () => void }) {
return (
<div className="runs">
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<div className="panel-head">
<History className="icon" style={{ width: 15, height: 15 }} />
<strong>运行记录</strong>
<span className="spacer" />
<span className="count-pill">0</span>
</div>
<div className="run-list">
<div className="empty">
<History className="icon" />
<strong>还没有运行记录</strong>
<span>执行引擎 Day 2 接入后,每次运行的状态、耗时、日志和截图会出现在这里。</span>
</div>
</div>
</section>
<section className="panel run-detail">
<div className="panel-head">
<Play className="icon" style={{ width: 15, height: 15 }} />
<strong>运行详情</strong>
</div>
<div className="run-detail-body">
<div className="empty" style={{ minHeight: 360 }}>
<Play className="icon" />
<strong>未选择运行</strong>
<span>左侧选择一条运行记录后,这里显示每个节点的状态、变量与证据。</span>
</div>
</div>
</section>
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
<div className="panel-head">
<strong>快捷操作</strong>
</div>
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 8 }}>
<button type="button" className="btn" onClick={onBack}>
<RotateCcw className="icon" /> 返回流程编排
</button>
<div className="field-hint" style={{ lineHeight: "17px" }}>
运行引擎完成后,这里会显示最近成功率、平均耗时和失败节点 TOP。
</div>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { Cpu, Database, FolderCog, HardDrive, Info } from "lucide-react";
import type { AppInfo, Catalog } from "@shared/types";
export function SettingsPage({ catalog }: { catalog: Catalog }) {
const [info, setInfo] = useState<AppInfo | null>(null);
useEffect(() => {
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
}, []);
const executable = catalog.components.filter((item) => item.implemented).length;
return (
<div className="settings">
<aside className="panel setting-nav">
<button type="button" className="active">
关于与运行环境
</button>
<button type="button">组件目录</button>
<button type="button">执行与证据</button>
<button type="button">浏览器白名单</button>
</aside>
<section className="panel setting-body">
<div className="setting-card">
<h3>
<Info className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
版本与运行环境
</h3>
<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>
</div>
<div className="setting-card">
<h3>
<Database className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
组件目录
</h3>
<dl className="kv">
<dt>目录总数</dt>
<dd>{catalog.counts.total} 项</dd>
<dt>厂商组件 / 控制 / 触发</dt>
<dd>
{catalog.counts.vendor} / {catalog.counts.native} / {catalog.counts.triggers}
</dd>
<dt>字段总数</dt>
<dd>{catalog.counts.fields}</dd>
<dt>已接入执行器</dt>
<dd>
{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)
</dd>
<dt>待接入</dt>
<dd>{catalog.counts.total - executable} 项(灰标展示)</dd>
</dl>
</div>
<div className="setting-card">
<h3>
<Cpu className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
执行方式
</h3>
<dl className="kv">
<dt>执行位置</dt>
<dd>本机主进程(无云端依赖)</dd>
<dt>浏览器</dt>
<dd>内嵌 WebContentsView + CDP(Day 2)</dd>
<dt>桌面</dt>
<dd>PowerShell 桥:UIA + SendInput(Day 2)</dd>
<dt>Office</dt>
<dd>exceljs / COM(Day 3)</dd>
</dl>
</div>
<div className="setting-card">
<h3>
<HardDrive className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
数据与证据
</h3>
<p className="field-hint" style={{ lineHeight: "18px" }}>
流程以 JSON 原子写入数据目录;运行记录与截图将写入 runs/&lt;runId&gt;/。运行期不加载 CDN、字体或云服务。
</p>
</div>
<div className="setting-card">
<h3>
<FolderCog className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
参考与边界
</h3>
<p className="field-hint" style={{ lineHeight: "18px" }}>
组件元数据来自项目内 1016 项目录(997 厂商 + 16 控制 + 3 触发)。厂商代码、二进制与模型不随产品分发;未接入执行器的组件
以灰标展示,运行到该节点会明确报错。
</p>
</div>
</section>
</div>
);
}

View File

@ -0,0 +1,204 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Search, Sparkles } from "lucide-react";
import type { Catalog, CatalogComponent } from "@shared/types";
import { groupIcon } from "../components/icons";
type StatusFilter = "all" | "executable" | "pending";
const ITEM_HEIGHT = 58;
const OVERSCAN = 5;
interface PaletteProps {
catalog: Catalog;
onAdd: (componentId: string) => void;
}
export function Palette({ catalog, onAdd }: PaletteProps) {
const [query, setQuery] = useState("");
const [status, setStatus] = useState<StatusFilter>("all");
const [group, setGroup] = useState<string>("all");
const [scrollTop, setScrollTop] = useState(0);
const [viewport, setViewport] = useState(520);
const listRef = useRef<HTMLDivElement>(null);
const executableCount = useMemo(
() => catalog.components.filter((item) => item.implemented).length,
[catalog]
);
const pendingCount = catalog.components.length - executableCount;
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
return catalog.components.filter((component) => {
if (group !== "all" && component.group !== group) return false;
if (status === "executable" && !component.implemented) return false;
if (status === "pending" && component.implemented) return false;
if (!keyword) return true;
const haystack = [
component.name,
component.id,
component.functionName,
component.description,
...(component.tags || [])
]
.join(" ")
.toLowerCase();
return haystack.includes(keyword);
});
}, [catalog.components, group, query, status]);
useEffect(() => {
if (!listRef.current) return;
const observer = new ResizeObserver((entries) => {
const next = entries[0]?.contentRect.height;
if (next) setViewport(next);
});
observer.observe(listRef.current);
return () => observer.disconnect();
}, []);
useEffect(() => {
setScrollTop(0);
if (listRef.current) listRef.current.scrollTop = 0;
}, [query, status, group]);
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN);
const end = Math.min(filtered.length, Math.ceil((scrollTop + viewport) / ITEM_HEIGHT) + OVERSCAN);
const rows = filtered.slice(start, end);
return (
<aside className="panel palette" aria-label="组件库">
<div className="palette-head">
<div className="palette-title-row">
<span className="palette-title">组件库</span>
<span className="palette-count">
{filtered.length} / {catalog.counts.total}
</span>
</div>
<label className="search-box">
<Search className="icon" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索名称 / ID / 标签"
aria-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>
<div className="group-row" role="tablist" aria-label="组件分组">
<button
type="button"
className={`group-chip${group === "all" ? " active" : ""}`}
onClick={() => setGroup("all")}
>
全部
</button>
{catalog.groups.map((item) => {
const Icon = groupIcon(item.id);
return (
<button
type="button"
key={item.id}
className={`group-chip${group === item.id ? " active" : ""}`}
onClick={() => setGroup(item.id)}
title={`${item.label}(${item.count})`}
>
<Icon className="icon" style={{ width: 11, height: 11, display: "inline-block" }} />
{item.label}
</button>
);
})}
</div>
</div>
<div
className="palette-list"
ref={listRef}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
>
<div className="palette-inner" style={{ height: filtered.length * ITEM_HEIGHT }}>
{rows.map((component, index) => {
const absolute = start + index;
return (
<PaletteRow
key={component.id}
component={component}
top={absolute * ITEM_HEIGHT}
onAdd={onAdd}
/>
);
})}
</div>
{filtered.length === 0 && (
<div className="empty" style={{ minHeight: 200 }}>
<Sparkles className="icon" />
<strong>没有匹配的组件</strong>
<span>换个关键词,或切换状态/分组筛选。</span>
</div>
)}
</div>
<div className="palette-more">
<ChevronDown className="icon" style={{ width: 12, height: 12 }} />
拖拽到画布,或单击追加;灰点为待接入。
</div>
</aside>
);
}
function PaletteRow({
component,
top,
onAdd
}: {
component: CatalogComponent;
top: number;
onAdd: (id: string) => void;
}) {
const Icon = groupIcon(component.group);
return (
<button
type="button"
draggable
className={`palette-row${component.implemented ? "" : " pending"}`}
data-group={component.group}
style={{ top }}
onDragStart={(event) => {
event.dataTransfer.setData("application/rpa-component", component.id);
event.dataTransfer.effectAllowed = "copy";
}}
onClick={() => onAdd(component.id)}
title={`${component.name}\n${component.id}\n${component.description || ""}`}
>
<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={`status-dot${component.implemented ? "" : " pending"}`} />
</button>
);
}

View File

@ -0,0 +1,673 @@
import { useMemo, useState } from "react";
import {
AlertTriangle,
Boxes,
ChevronRight,
Copy,
FolderOpen,
Info,
Play,
Plus,
Settings2,
SlidersHorizontal,
Trash2,
Variable
} from "lucide-react";
import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { groupIcon } from "../components/icons";
import { containerSlots } from "../canvas/ContainerNode";
type Tab = "props" | "vars" | "run";
interface PropertiesProps {
catalog: Catalog;
onToast: (message: string, kind?: "ok" | "error") => void;
}
function textOf(value: unknown): string {
if (value === undefined || value === null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function boolOf(value: unknown): boolean {
return value === true || value === "1" || value === "true" || value === "开启";
}
function numberOr(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function Properties({ catalog, onToast }: PropertiesProps) {
const [tab, setTab] = useState<Tab>("props");
const flow = useEditorStore((state) => state.flow);
const selectedNodeId = useEditorStore((state) => state.selectedNodeId);
const updateNode = useEditorStore((state) => state.updateNode);
const updateValue = useEditorStore((state) => state.updateValue);
const updateOption = useEditorStore((state) => state.updateOption);
const duplicateNode = useEditorStore((state) => state.duplicateNode);
const removeNode = useEditorStore((state) => state.removeNode);
const addVariable = useEditorStore((state) => state.addVariable);
const removeVariable = useEditorStore((state) => state.removeVariable);
const node = useMemo(
() => flow?.nodes.find((item) => item.id === selectedNodeId) || null,
[flow, selectedNodeId]
);
const component = useMemo(
() => (node ? catalog.components.find((item) => item.id === node.componentId) || null : null),
[catalog, node]
);
const parent = useMemo(
() => (node?.parentId ? flow?.nodes.find((item) => item.id === node.parentId) || null : null),
[flow, node]
);
const childNodes = useMemo(
() => (node ? flow?.nodes.filter((item) => item.parentId === node.id) || [] : []),
[flow, node]
);
if (!flow) return null;
return (
<aside className="panel props" aria-label="属性面板">
{node && component ? (
<>
<div className="props-head">
<span className={`props-icon tone-${component.group}`}>
{(() => {
const Icon = groupIcon(component.group);
return <Icon className="icon" />;
})()}
</span>
<div className="props-title-wrap">
<span className="props-kicker">
{parent
? `位于 ${parent.label || parent.componentId}`
: component.kind === "native"
? "控制节点"
: component.kind === "trigger"
? "触发器"
: "组件"}
</span>
<strong className="props-title">{node.label || component.name}</strong>
<span className="props-id">{component.id}</span>
</div>
</div>
<div className="tabs" role="tablist">
<button type="button" className={tab === "props" ? "active" : ""} onClick={() => setTab("props")}>
属性
</button>
<button type="button" className={tab === "vars" ? "active" : ""} onClick={() => setTab("vars")}>
变量
</button>
<button type="button" className={tab === "run" ? "active" : ""} onClick={() => setTab("run")}>
运行
</button>
</div>
<div className="props-body">
{tab === "props" && (
<PropertyTab
node={node}
component={component}
parent={parent}
childNodes={childNodes}
onLabel={(label) => updateNode(node.id, { label })}
onChange={(key, value) => updateValue(node.id, key, value)}
onChangeOption={(id, key, value) =>
updateOption(id, key, value as string | number | boolean | undefined)
}
onDetach={() => updateNode(node.id, { parentId: null, slot: undefined })}
onToast={onToast}
/>
)}
{tab === "vars" && (
<VariablesTab
flowVariables={flow.variables}
nodes={flow.nodes}
onAdd={addVariable}
onRemove={removeVariable}
/>
)}
{tab === "run" && <RunTab node={node} component={component} />}
</div>
<div className="props-foot">
<span>{component.implemented ? "已接入执行器" : "待接入执行器"}</span>
<div style={{ display: "flex", gap: 4 }}>
<button type="button" className="icon-btn" title="复制节点" onClick={() => duplicateNode(node.id)}>
<Copy className="icon" />
</button>
<button type="button" className="icon-btn" title="删除节点" onClick={() => removeNode(node.id)}>
<Trash2 className="icon" />
</button>
</div>
</div>
</>
) : (
<>
<div className="props-head">
<span className="props-icon">
<Settings2 className="icon" />
</span>
<div className="props-title-wrap">
<span className="props-kicker">未选择节点</span>
<strong className="props-title">节点属性</strong>
<span className="props-id">从画布选择一个节点</span>
</div>
</div>
<div className="prop-empty">
<SlidersHorizontal className="icon" />
<strong>未选择节点</strong>
<span>单击画布节点查看并编辑参数、失败策略与输出变量。</span>
</div>
</>
)}
</aside>
);
}
function PropertyTab({
node,
component,
parent,
childNodes,
onLabel,
onChange,
onChangeOption,
onDetach,
onToast
}: {
node: FlowNode;
component: Catalog["components"][number];
parent: FlowNode | null;
childNodes: FlowNode[];
onLabel: (label: string) => void;
onChange: (key: string, value: unknown) => void;
onChangeOption: (id: string, key: keyof FlowNode["options"], value: unknown) => void;
onDetach: () => void;
onToast: (message: string, kind?: "ok" | "error") => void;
}) {
const [advancedOpen, setAdvancedOpen] = useState(false);
const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden");
const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden");
const slots = component.slots.length > 0 ? containerSlots(component) : [];
return (
<>
{!component.implemented && (
<div className="notice warn">
<AlertTriangle className="icon" />
<div>
<strong>当前版本无执行器</strong>
<span>可保存与连线;运行到该节点会停止并给出替代建议。</span>
</div>
</div>
)}
{parent && (
<div className="notice">
<Boxes className="icon" />
<div style={{ flex: 1 }}>
<strong>位于容器「{parent.label || parent.componentId}」</strong>
<span>{node.slot ? `槽位:${node.slot}` : "未指定槽位"}</span>
</div>
<button type="button" className="mini-btn" onClick={onDetach}>
移出
</button>
</div>
)}
{slots.length > 0 && (
<section className="prop-section">
<div className="section-title">
<Boxes className="icon" /> 容器内容
</div>
{slots.map((slot) => {
const count = childNodes.filter((child) => child.slot === slot.key).length;
return (
<div className="slot-summary" key={slot.key}>
<span>{slot.label}</span>
<strong>{count} 个步骤</strong>
</div>
);
})}
<div className="field-hint">把组件拖到画布上的容器分支,或选中容器后点击左侧组件追加。</div>
</section>
)}
<section className="prop-section">
<div className="section-title">
<Info className="icon" /> 基本
</div>
<div className="field">
<label className="field-label">节点名称</label>
<input
className="text-input"
value={node.label ?? ""}
placeholder={component.name}
onChange={(event) => onLabel(event.target.value)}
maxLength={60}
/>
</div>
<div className="field">
<label className="field-label">组件</label>
<div className="readonly-line">
<span>{component.name}</span>
<code>{component.id}</code>
</div>
</div>
</section>
<section className="prop-section">
<div className="section-title">
<SlidersHorizontal className="icon" /> 输入参数
</div>
{basic.length === 0 && <div className="field-hint">该组件没有普通输入字段。</div>}
{basic.map((field) => (
<FieldControl
key={field.key}
field={field}
node={node}
onChange={onChange}
onToast={onToast}
/>
))}
{advanced.length > 0 && (
<>
<button
type="button"
className={`advanced-toggle${advancedOpen ? " open" : ""}`}
onClick={() => setAdvancedOpen((value) => !value)}
>
<ChevronRight className="icon" />
高级参数({advanced.length})
</button>
{advancedOpen &&
advanced.map((field) => (
<FieldControl
key={field.key}
field={field}
node={node}
onChange={onChange}
onToast={onToast}
/>
))}
</>
)}
</section>
<section className="prop-section">
<div className="section-title">
<Variable className="icon" /> 输出
</div>
<div className="field">
<label className="field-label">输出到变量</label>
<input
className="text-input"
value={textOf(node.options.outputVar)}
placeholder={component.output.suggestedName || "可选,例如 result"}
onChange={(event) => onChangeOption(node.id, "outputVar", event.target.value)}
/>
<span className="field-hint">运行结果写入该变量;下游节点用 {"${变量名}"} 引用。</span>
</div>
</section>
<section className="prop-section">
<div className="section-title">
<Settings2 className="icon" /> 失败策略
</div>
<div className="field-grid">
<NumberField
label="超时(ms)"
value={numberOr(node.options.timeoutMs, 30000)}
min={0}
max={604800000}
onChange={(value) => onChangeOption(node.id, "timeoutMs", value)}
/>
<NumberField
label="重试次数"
value={numberOr(node.options.retryCount, 0)}
min={0}
max={100}
onChange={(value) => onChangeOption(node.id, "retryCount", value)}
/>
<NumberField
label="重试间隔(ms)"
value={numberOr(node.options.retryIntervalMs, 1000)}
min={1000}
max={600000}
onChange={(value) => onChangeOption(node.id, "retryIntervalMs", value)}
/>
<NumberField
label="前置延时(ms)"
value={numberOr(node.options.delayBeforeMs, 0)}
min={0}
max={100000000}
onChange={(value) => onChangeOption(node.id, "delayBeforeMs", value)}
/>
<NumberField
label="后置延时(ms)"
value={numberOr(node.options.delayAfterMs, 0)}
min={0}
max={100000000}
onChange={(value) => onChangeOption(node.id, "delayAfterMs", value)}
/>
</div>
<label className="checkbox-row">
<input
type="checkbox"
checked={Boolean(node.options.skipOnError)}
onChange={(event) => onChangeOption(node.id, "skipOnError", event.target.checked)}
/>
失败后跳过并继续
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={node.options.logException !== false}
onChange={(event) => onChangeOption(node.id, "logException", event.target.checked)}
/>
记录异常堆栈
</label>
</section>
</>
);
}
function FieldControl({
field,
node,
onChange,
onToast
}: {
field: CatalogField;
node: FlowNode;
onChange: (key: string, value: unknown) => void;
onToast: (message: string, kind?: "ok" | "error") => void;
}) {
const raw = node.values[field.key] ?? field.defaultValue;
const pickPath = async (directories: boolean) => {
try {
const selected = await window.rpa.dialog.open({
title: field.label,
directories,
filters: directories
? undefined
: [
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
{ name: "全部文件", extensions: ["*"] }
]
});
if (selected) onChange(field.key, selected);
} catch (error) {
onToast(error instanceof Error ? error.message : String(error), "error");
}
};
if (field.widget === "boolean") {
return (
<label className="checkbox-row">
<input type="checkbox" checked={boolOf(raw)} onChange={(event) => onChange(field.key, event.target.checked)} />
{field.label}
{field.required && <span className="required">*</span>}
</label>
);
}
return (
<div className="field">
<label className="field-label">
{field.label}
{field.required && <span className="required">*</span>}
{field.unit && <span className="field-hint">({field.unit})</span>}
</label>
{field.widget === "textarea" || field.widget === "code" ? (
<textarea
className="textarea-input"
value={textOf(raw)}
placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)}
rows={field.widget === "code" ? 5 : 3}
/>
) : field.widget === "select" && field.options?.length ? (
<select className="select-input" value={textOf(raw)} onChange={(event) => onChange(field.key, event.target.value)}>
<option value="">请选择</option>
{field.options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : field.widget === "radio" && field.options?.length ? (
<div className="chip-row">
{field.options.map((option) => (
<button
type="button"
key={option}
className={`option-chip${textOf(raw) === option ? " active" : ""}`}
onClick={() => onChange(field.key, option)}
>
{option}
</button>
))}
</div>
) : field.widget === "file" || field.widget === "folder" ? (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "folder" ? "选择目录" : "选择文件")}
onChange={(event) => onChange(field.key, event.target.value)}
/>
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
</button>
</div>
) : field.widget === "element" ? (
<div className="field-row">
<input
className="text-input"
value={textOf(raw)}
placeholder="css:#submit / aria:button"
onChange={(event) => onChange(field.key, event.target.value)}
/>
<button type="button" className="mini-btn" disabled title="Day 2 接入浏览器拾取器">
拾取
</button>
</div>
) : field.widget === "secret" ? (
<input
className="text-input"
type="password"
value={textOf(raw)}
placeholder={field.placeholder || "密钥引用"}
onChange={(event) => onChange(field.key, event.target.value)}
/>
) : field.widget === "number" ? (
<input
className="text-input"
type="number"
value={textOf(raw)}
placeholder={field.placeholder || ""}
onChange={(event) => onChange(field.key, event.target.value)}
/>
) : (
<input
className="text-input"
value={textOf(raw)}
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
onChange={(event) => onChange(field.key, event.target.value)}
/>
)}
{field.description && <span className="field-hint">{field.description}</span>}
{field.valueTypes?.length > 0 && (
<span className="field-hint mono">{field.valueTypes.join(" · ")}</span>
)}
</div>
);
}
function NumberField({
label,
value,
min,
max,
onChange
}: {
label: string;
value: number;
min: number;
max: number;
onChange: (value: number) => void;
}) {
return (
<label className="field compact">
<span className="field-label">{label}</span>
<input
className="text-input"
type="number"
value={value}
min={min}
max={max}
onChange={(event) => {
const next = Number(event.target.value);
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
}}
/>
</label>
);
}
function VariablesTab({
flowVariables,
nodes,
onAdd,
onRemove
}: {
flowVariables: FlowVariable[];
nodes: FlowNode[];
onAdd: (variable: FlowVariable) => void;
onRemove: (name: string) => void;
}) {
const [name, setName] = useState("");
const [type, setType] = useState("string");
const [defaultValue, setDefaultValue] = useState("");
const outputs = nodes.filter((node) => node.options.outputVar);
const submit = () => {
const trimmed = name.trim();
if (!/^[A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*$/.test(trimmed)) return;
onAdd({ name: trimmed, type, defaultValue, scope: "flow" });
setName("");
setDefaultValue("");
};
return (
<>
<section className="prop-section">
<div className="section-title">
<Variable className="icon" /> 流程变量
</div>
{flowVariables.length === 0 && <div className="field-hint">还没有流程变量。可在下方新增,或用节点输出变量。</div>}
{flowVariables.map((variable) => (
<div className="var-row" key={variable.name}>
<span className="var-name">{variable.name}</span>
<span className="var-type">{variable.type}</span>
<button type="button" className="icon-btn" title="删除变量" onClick={() => onRemove(variable.name)}>
<Trash2 className="icon" />
</button>
</div>
))}
<div className="field-grid">
<label className="field compact">
<span className="field-label">名称</span>
<input className="text-input" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如 orderNo" />
</label>
<label className="field compact">
<span className="field-label">类型</span>
<select className="select-input" value={type} onChange={(event) => setType(event.target.value)}>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="list">list</option>
<option value="dict">dict</option>
<option value="excel">excel</option>
<option value="browser">browser</option>
<option value="any">any</option>
</select>
</label>
</div>
<div className="field">
<span className="field-label">默认值</span>
<input className="text-input" value={defaultValue} onChange={(event) => setDefaultValue(event.target.value)} />
</div>
<button type="button" className="btn" onClick={submit} disabled={!name.trim()}>
<Plus className="icon" /> 新增变量
</button>
</section>
<section className="prop-section">
<div className="section-title">
<Info className="icon" /> 节点输出
</div>
{outputs.length === 0 && <div className="field-hint">还没有节点配置输出变量。</div>}
{outputs.map((node) => (
<div className="var-row" key={node.id}>
<span className="var-name">{textOf(node.options.outputVar)}</span>
<span className="var-type">{node.componentId}</span>
</div>
))}
</section>
</>
);
}
function RunTab({ node, component }: { node: FlowNode; component: Catalog["components"][number] }) {
return (
<>
<section className="prop-section">
<div className="section-title">
<Play className="icon" /> 运行状态
</div>
<div className="run-facts">
<div>
<span>状态</span>
<strong>未运行</strong>
</div>
<div>
<span>执行器</span>
<strong>{component.implemented ? "已接入" : "待接入"}</strong>
</div>
<div>
<span>超时</span>
<strong>{numberOr(node.options.timeoutMs, 30000)} ms</strong>
</div>
<div>
<span>重试</span>
<strong>{numberOr(node.options.retryCount, 0)} 次</strong>
</div>
</div>
<button type="button" className="btn" disabled title="Day 2 接入执行内核">
<Play className="icon" /> 单步运行(Day 2)
</button>
</section>
<section className="prop-section">
<div className="section-title">
<Info className="icon" /> 说明
</div>
<div className="field-hint" style={{ lineHeight: "17px" }}>
运行时会按顺序执行节点,记录状态、耗时、输出变量与截图证据。待接入节点会在此处显示失败原因与建议。
</div>
</section>
</>
);
}

View File

@ -0,0 +1,298 @@
import { create } from "zustand";
import type {
Catalog,
CatalogComponent,
FlowDefinition,
FlowEdge,
FlowNode,
FlowNodeOptions,
FlowVariable
} from "@shared/types";
const clone = <T,>(value: T): T => structuredClone(value);
let nodeSeq = 0;
function nextNodeId(): string {
nodeSeq += 1;
return `node_${Date.now().toString(36)}_${nodeSeq}`;
}
function defaultValues(component: CatalogComponent): Record<string, unknown> {
const values: Record<string, unknown> = {};
for (const field of component.fields) {
if (field.widget === "hidden") continue;
if (field.defaultValue !== "") values[field.key] = field.defaultValue;
}
return values;
}
function defaultOptions(): FlowNodeOptions {
return {
timeoutMs: 30000,
retryCount: 0,
retryIntervalMs: 1000,
skipOnError: false,
logException: true
};
}
interface EditorState {
flow: FlowDefinition | null;
catalog: Catalog | null;
selectedNodeId: string | null;
dirty: boolean;
past: FlowDefinition[];
future: FlowDefinition[];
setCatalog: (catalog: Catalog) => void;
loadFlow: (flow: FlowDefinition) => void;
selectNode: (id: string | null) => void;
addNode: (componentId: string, position: { x: number; y: number }) => string | null;
addChild: (parentId: string, slot: string, componentId: string) => string | null;
moveChild: (childId: string, direction: -1 | 1) => void;
updateNode: (id: string, patch: Partial<FlowNode>) => void;
updateValue: (id: string, key: string, value: unknown) => void;
updateOption: <K extends keyof FlowNodeOptions>(id: string, key: K, value: FlowNodeOptions[K]) => void;
moveNode: (id: string, position: { x: number; y: number }) => void;
removeNode: (id: string) => void;
duplicateNode: (id: string) => void;
addEdge: (edge: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }) => void;
removeEdge: (id: string) => void;
renameFlow: (name: string) => void;
addVariable: (variable: FlowVariable) => void;
removeVariable: (name: string) => void;
undo: () => void;
redo: () => void;
markSaved: () => void;
reset: () => void;
}
export const useEditorStore = create<EditorState>((set, get) => {
const commit = (mutate: (draft: FlowDefinition) => void) => {
const current = get().flow;
if (!current) return;
const draft = clone(current);
mutate(draft);
draft.meta.updatedAt = new Date().toISOString();
set({
flow: draft,
past: [...get().past.slice(-49), current],
future: [],
dirty: true
});
};
return {
flow: null,
catalog: null,
selectedNodeId: null,
dirty: false,
past: [],
future: [],
setCatalog: (catalog) => set({ catalog }),
loadFlow: (flow) => set({ flow, selectedNodeId: null, dirty: false, past: [], future: [] }),
selectNode: (id) => set({ selectedNodeId: id }),
addNode: (componentId, position) => {
const state = get();
const component = state.catalog?.components.find((item) => item.id === componentId);
if (!component || !state.flow) return null;
const id = nextNodeId();
const node: FlowNode = {
id,
componentId,
position,
values: defaultValues(component),
options: defaultOptions(),
enabled: true
};
commit((draft) => {
draft.nodes.push(node);
});
set({ selectedNodeId: id });
return id;
},
updateNode: (id, patch) => {
commit((draft) => {
const node = draft.nodes.find((item) => item.id === id);
if (!node) return;
Object.assign(node, patch);
});
},
addChild: (parentId, slot, componentId) => {
const state = get();
const component = state.catalog?.components.find((item) => item.id === componentId);
const parent = state.flow?.nodes.find((item) => item.id === parentId);
if (!component || !parent || !state.flow) return null;
const siblings = state.flow.nodes.filter((item) => item.parentId === parentId && item.slot === slot);
const id = nextNodeId();
const node: FlowNode = {
id,
componentId,
parentId,
slot,
position: { x: 0, y: siblings.length * 52 },
values: defaultValues(component),
options: defaultOptions(),
enabled: true
};
commit((draft) => {
const lastSibling = [...draft.nodes]
.reverse()
.find((item) => item.parentId === parentId && item.slot === slot);
const index = lastSibling ? draft.nodes.indexOf(lastSibling) + 1 : draft.nodes.indexOf(parent) + 1;
draft.nodes.splice(Math.max(0, index), 0, node);
});
set({ selectedNodeId: id });
return id;
},
moveChild: (childId, direction) => {
commit((draft) => {
const child = draft.nodes.find((item) => item.id === childId);
if (!child?.parentId) return;
const siblings = draft.nodes.filter(
(item) => item.parentId === child.parentId && item.slot === child.slot
);
const index = siblings.findIndex((item) => item.id === childId);
const target = index + direction;
if (index < 0 || target < 0 || target >= siblings.length) return;
const a = draft.nodes.indexOf(siblings[index]);
const b = draft.nodes.indexOf(siblings[target]);
[draft.nodes[a], draft.nodes[b]] = [draft.nodes[b], draft.nodes[a]];
});
},
updateValue: (id, key, value) => {
commit((draft) => {
const node = draft.nodes.find((item) => item.id === id);
if (!node) return;
node.values = { ...node.values, [key]: value };
});
},
updateOption: (id, key, value) => {
commit((draft) => {
const node = draft.nodes.find((item) => item.id === id);
if (!node) return;
node.options = { ...node.options, [key]: value };
});
},
moveNode: (id, position) => {
commit((draft) => {
const node = draft.nodes.find((item) => item.id === id);
if (!node) return;
node.position = position;
});
},
removeNode: (id) => {
commit((draft) => {
const doomed = new Set<string>([id]);
let changed = true;
while (changed) {
changed = false;
for (const node of draft.nodes) {
if (node.parentId && doomed.has(node.parentId) && !doomed.has(node.id)) {
doomed.add(node.id);
changed = true;
}
}
}
draft.nodes = draft.nodes.filter((node) => !doomed.has(node.id));
draft.edges = draft.edges.filter((edge) => !doomed.has(edge.source) && !doomed.has(edge.target));
});
set({ selectedNodeId: null });
},
duplicateNode: (id) => {
const state = get();
const source = state.flow?.nodes.find((item) => item.id === id);
if (!source || !state.flow) return;
const copy: FlowNode = clone(source);
copy.id = nextNodeId();
copy.position = { x: source.position.x + 36, y: source.position.y + 36 };
copy.label = source.label ? `${source.label} 副本` : undefined;
commit((draft) => {
draft.nodes.push(copy);
});
set({ selectedNodeId: copy.id });
},
addEdge: ({ source, target, sourceHandle, targetHandle }) => {
if (!source || !target || source === target) return;
const state = get();
if (!state.flow) return;
if (state.flow.edges.some((edge) => edge.source === source && edge.target === target)) return;
const edge: FlowEdge = {
id: `edge_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 5)}`,
source,
target,
sourceHandle: sourceHandle || undefined,
targetHandle: targetHandle || undefined
};
commit((draft) => {
draft.edges.push(edge);
});
},
removeEdge: (id) => {
commit((draft) => {
draft.edges = draft.edges.filter((edge) => edge.id !== id);
});
},
renameFlow: (name) => {
commit((draft) => {
draft.name = name;
});
},
addVariable: (variable) => {
commit((draft) => {
if (draft.variables.some((item) => item.name === variable.name)) return;
draft.variables.push(variable);
});
},
removeVariable: (name) => {
commit((draft) => {
draft.variables = draft.variables.filter((item) => item.name !== name);
});
},
undo: () => {
const { past, flow, future } = get();
if (!past.length || !flow) return;
const previous = past[past.length - 1];
set({
flow: previous,
past: past.slice(0, -1),
future: [flow, ...future].slice(0, 50),
dirty: true
});
},
redo: () => {
const { future, flow, past } = get();
if (!future.length || !flow) return;
const next = future[0];
set({
flow: next,
future: future.slice(1),
past: [...past, flow].slice(-50),
dirty: true
});
},
markSaved: () => set({ dirty: false }),
reset: () => set({ flow: null, selectedNodeId: null, dirty: false, past: [], future: [] })
};
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,42 @@
:root {
--blue: #2264e5;
--blue-dark: #155bc0;
--blue-soft: #eaf1ff;
--blue-mist: #f3f7ff;
--ink: #172033;
--ink-2: #4e5a6b;
--muted: #788494;
--faint: #a1acba;
--bg: #e7edf3;
--panel: #f2f5f8;
--workspace: #f3f6f9;
--surface: #ffffff;
--soft: #f5f7fa;
--soft-2: #edf2f7;
--line: rgba(23, 32, 51, 0.075);
--line-strong: rgba(23, 32, 51, 0.12);
--success: #079455;
--success-deep: #067647;
--success-bg: #e9f7ee;
--warn: #b45309;
--warn-deep: #7a2e0e;
--warn-bg: #fff4e5;
--danger: #d92d20;
--danger-deep: #912018;
--danger-bg: #feeceb;
--teal: #0e7b6b;
--teal-bg: #e8f7f3;
--violet: #6b46c1;
--violet-bg: #f2edff;
--amber: #b56a08;
--amber-bg: #fff6e8;
--radius: 20px;
--shadow-app: 0 24px 70px rgba(25, 39, 60, 0.16);
--shadow-panel: 0 6px 20px rgba(35, 55, 72, 0.045);
--shadow-pop: 0 18px 44px rgba(23, 38, 58, 0.16);
--focus: 0 0 0 4px rgba(34, 100, 229, 0.12);
--rail-w: 76px;
--titlebar-h: 58px;
--font: "MiSans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
--mono: "Cascadia Code", "Consolas", "SFMono-Regular", monospace;
}

197
src/shared/types.ts Normal file
View File

@ -0,0 +1,197 @@
export type CatalogWidget =
| "text"
| "textarea"
| "number"
| "select"
| "radio"
| "boolean"
| "file"
| "folder"
| "element"
| "code"
| "secret"
| "region"
| "hidden";
export interface CatalogField {
key: string;
label: string;
widget: CatalogWidget;
type: "string" | "number" | "boolean" | "object";
displayType: string;
required: boolean;
defaultValue: string;
placeholder: string;
description: string;
advanced: boolean;
valueTypes: string[];
options?: string[];
unit?: string;
}
export interface CatalogOutput {
label: string;
key: string;
suggestedName: string;
valueTypes: string[];
fields: Array<{ key: string; label: string; defaultValue: string; valueTypes: string[] }>;
}
export interface CatalogComponent {
id: string;
name: string;
kind: "vendor" | "native" | "trigger";
group: string;
categories: string[];
categorySignature: string;
description: string;
messageTemplate: string;
baseType: string;
functionName: string;
functionCalls: Array<{ function: string; args: string[]; assignTo: string }>;
version: string;
tags: string[];
platformTag: string | null;
icon: string;
risk: "low" | "high";
fields: CatalogField[];
output: CatalogOutput;
slots: string[];
nodeKind?: string;
implemented?: boolean;
}
export interface CatalogGroup {
id: string;
label: string;
icon: string;
count: number;
}
export interface Catalog {
schemaVersion: number;
generatedAt: string;
counts: {
total: number;
vendor: number;
native: number;
triggers: number;
fields: number;
};
groups: CatalogGroup[];
components: CatalogComponent[];
}
export interface FlowVariable {
name: string;
type: string;
defaultValue?: string;
description?: string;
scope?: "flow" | "local";
}
export interface FlowNodeOptions {
outputVar?: string;
delayBeforeMs?: number;
delayAfterMs?: number;
timeoutMs?: number;
retryCount?: number;
retryIntervalMs?: number;
skipOnError?: boolean;
logException?: boolean;
}
export interface FlowNode {
id: string;
componentId: string;
label?: string;
position: { x: number; y: number };
values: Record<string, unknown>;
options: FlowNodeOptions;
parentId?: string | null;
slot?: string;
enabled?: boolean;
width?: number;
height?: number;
}
export interface FlowEdge {
id: string;
source: string;
target: string;
sourceHandle?: string | null;
targetHandle?: string | null;
label?: string;
}
export interface FlowDefinition {
schemaVersion: 1;
id: string;
name: string;
description?: string;
variables: FlowVariable[];
nodes: FlowNode[];
edges: FlowEdge[];
meta: {
createdAt: string;
updatedAt: string;
appVersion: string;
};
}
export interface FlowSummary {
id: string;
name: string;
updatedAt: string;
nodeCount: number;
}
export interface AppInfo {
version: string;
electron: string;
chrome: string;
node: string;
userData: string;
catalogPath: string;
packaged: boolean;
}
export interface RunNodeState {
nodeId: string;
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
startedAt?: string;
finishedAt?: string;
durationMs?: number;
output?: unknown;
error?: RunError;
evidence?: string[];
attempt?: number;
}
export interface RunError {
code: string;
reason: string;
recommendation?: string;
location?: { flowId?: string; nodeId?: string; componentId?: string; arg?: string };
detail?: string;
}
export interface RunEvent {
type:
| "run.started"
| "node.started"
| "node.log"
| "node.finished"
| "run.finished";
runId: string;
nodeId?: string;
level?: "info" | "warn" | "error";
message?: string;
status?: string;
output?: unknown;
error?: RunError;
durationMs?: number;
evidence?: string[];
attempt?: number;
startedAt?: string;
}

22
tsconfig.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"baseUrl": ".",
"paths": {
"@shared/*": ["src/shared/*"],
"@renderer/*": ["src/renderer/src/*"]
},
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "electron.vite.config.ts", "scripts/**/*.mjs"],
"exclude": ["node_modules", "out", "release"]
}