feat: 完善RPA编排与本地能力接入

This commit is contained in:
yu 2026-10-27 11:10:38 +08:00
parent 6a2cc24898
commit c03380168d
32 changed files with 2763 additions and 185 deletions

View File

@ -2,13 +2,16 @@
画布式 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 本地原子保存、自动保存、重启恢复
@ -47,6 +50,6 @@ docs 现状分析、实施方案
## 边界
- 1016 项目录中当前只有 15 项标记为可执行(控制/触发/基础);其余灰标「待接入」,运行到该节点会明确失败。
- 1016 项目录中当前有 259 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。

View File

@ -4,6 +4,8 @@
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器、HTTP、Excel/CSV、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **259 项可执行 / 1016 项总目录**。当前 5 个测试文件共 20 条测试,覆盖计划能力审计、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、直线/IF/循环/重试/跳过、主出口与异常出口语义。未通过实际场景验证的变体仍不计入最终交付数量。
---
## 0. 决策记录(2026-10-26 确认)

View File

@ -283,6 +283,33 @@ export class BrowserSession implements BrowserLike {
return this.evaluate(script);
}
async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise<string> {
const targetFolder = path.resolve(folder);
await fs.mkdir(targetFolder, { recursive: true });
return new Promise<string>((resolve, reject) => {
let settled = false;
const browserSession = this.win.webContents.session;
const onDownload = (_event: Electron.Event, item: Electron.DownloadItem) => {
const target = path.join(targetFolder, filename || item.getFilename());
item.setSavePath(target);
item.once("done", (_doneEvent, state) => {
if (state === "completed") finish(undefined, target);
else finish(new RuntimeError("browser_error", `下载未完成:${state}`));
});
};
const finish = (error?: Error, filePath?: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
browserSession.removeListener("will-download", onDownload);
error ? reject(error) : resolve(filePath || "");
};
const timer = setTimeout(() => finish(new RuntimeError("timeout", `下载超时(${timeoutMs}ms)`)), timeoutMs);
browserSession.once("will-download", onDownload);
void this.click(selector).catch((error) => finish(error instanceof Error ? error : new Error(String(error))));
});
}
async screenshot(filePath: string): Promise<string> {
const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" });
const target = path.resolve(filePath);

View File

@ -99,13 +99,26 @@ register(["Element_SelectOperation_v1", "Element_SelectOperation"], async (ctx,
return result;
});
register(["WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle"], async (ctx, node, args) => {
register([
"WebBrowser_RunJs", "WebBrowser_RunJsInIeByTitle", "WebBrowser_RunJsInIeBySelector",
"WebBrowser_RunJsInIeBySelector_v1", "WebBrowser_RunJsInIeBySelector_v2", "WebBrowser_RunJsInIeBySelector_v3",
"WebBrowser_RunJsInIeBySelectorGround"
], async (ctx, node, args) => {
const browser = await session(ctx);
const script = String(args.jsCode ?? args.script ?? args.code ?? "");
if (!script) throw new RuntimeError("validation_error", "JS 代码为空", { nodeId: node.id, componentId: node.componentId });
return browser.runJs(script);
});
register(["WebBrowser_SaveAs", "WebBrowser_SaveAs_V1"], async (ctx, node, args) => {
const browser = await session(ctx);
const folder = String(args.path ?? "");
if (!folder) throw new RuntimeError("validation_error", "下载保存目录为空", { nodeId: node.id, componentId: node.componentId });
const file = await browser.download(selectorOf(args), folder, String(args.filename ?? ""), Number(args.timeout ?? 10000));
ctx.log(`文件已下载 ${file}`, "info", node.id);
return file;
});
register("WebBrowser_Screenshot", async (ctx, node, args) => {
const browser = await session(ctx);
const target = String(args.file_path ?? args.image_path ?? `${ctx.evidenceDir}/${node.id}.png`);

View File

@ -0,0 +1,162 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import net from "node:net";
import { describe, expect, it, afterEach } from "vitest";
import { PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import "./desktop";
import "./office";
import "./pdf";
import "./message";
import "./word";
import { findImage } from "./vision";
const tempDirs: string[] = [];
const node = (componentId: string): FlowNode => ({
id: `smoke_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
const component = {} as CatalogComponent;
const context = {} as RunContext;
async function tempDir(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-capability-smoke-"));
tempDirs.push(directory);
return directory;
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("capability registration smoke", () => {
it("creates, writes, reads, and saves an Excel workbook", async () => {
const directory = await tempDir();
const create = getHandler("Excel_Cs_NewV5");
const write = getHandler("Excel_Cs_WriteCell");
const read = getHandler("Excel_Cs_ReadRangeV2");
const insert = getHandler("Excel_Cs_Insert");
const sheets = getHandler("Excel_GetSheetsName");
expect(create && write && read && insert && sheets).toBeTruthy();
const handle = await create!(context, node("Excel_Cs_NewV5"), { path: directory, name: "smoke.xlsx", override: "1" }, component);
await write!(context, node("Excel_Cs_WriteCell"), { workbook: handle, sheet: "Sheet1", row: "1", column: "A", content: "RPA", save: "1" }, component);
const table = await read!(context, node("Excel_Cs_ReadRangeV2"), { workbook: handle, sheet: "Sheet1", range: "A1:A1" }, component);
await insert!(context, node("Excel_Cs_Insert"), { workbook: handle, name: "结果", save: "1" }, component);
expect(table).toEqual([["RPA"]]);
await expect(sheets!(context, node("Excel_GetSheetsName"), { workbook: handle }, component)).resolves.toEqual(["Sheet1", "结果"]);
await expect(readFile(path.join(directory, "smoke.xlsx"))).resolves.toBeTruthy();
});
it("counts and concatenates PDF files", async () => {
const directory = await tempDir();
const first = await PDFDocument.create();
first.addPage([120, 120]);
const second = await PDFDocument.create();
second.addPage([120, 120]);
const firstPath = path.join(directory, "one.pdf");
const secondPath = path.join(directory, "two.pdf");
await writeFile(firstPath, await first.save());
await writeFile(secondPath, await second.save());
const count = getHandler("PDF_GetPdfPageNum");
const concat = getHandler("PDF_ConcatPdf");
expect(count && concat).toBeTruthy();
await expect(count!(context, node("PDF_GetPdfPageNum"), { fileInPath: firstPath }, component)).resolves.toBe(1);
const output = await concat!(context, node("PDF_ConcatPdf"), { newPdfPath: directory, newPdfName: "merged.pdf", pdfList: JSON.stringify([firstPath, secondPath]) }, component);
expect(output).toBe(path.join(directory, "merged.pdf"));
const merged = await PDFDocument.load(await readFile(output as string));
expect(merged.getPageCount()).toBe(2);
});
it("executes a local desktop command through the registered bridge", async () => {
const execute = getHandler("System_ExecuteCommand");
expect(execute).toBeTruthy();
await expect(execute!(context, node("System_ExecuteCommand"), { action: "Write-Output smoke" }, component)).resolves.toContain("smoke");
});
it("finds an exact PNG template inside a larger screenshot", async () => {
const directory = await tempDir();
const screen = new PNG({ width: 48, height: 36 });
const template = new PNG({ width: 8, height: 8 });
screen.data.fill(255);
for (let y = 0; y < template.height; y += 1) {
for (let x = 0; x < template.width; x += 1) {
const templateIndex = (y * template.width + x) * 4;
template.data[templateIndex] = 220;
template.data[templateIndex + 1] = 20;
template.data[templateIndex + 2] = 30;
template.data[templateIndex + 3] = 255;
const screenIndex = ((12 + y) * screen.width + 17 + x) * 4;
template.data.copy(screen.data, screenIndex, templateIndex, templateIndex + 4);
}
}
const screenPath = path.join(directory, "screen.png");
const templatePath = path.join(directory, "template.png");
await writeFile(screenPath, PNG.sync.write(screen));
await writeFile(templatePath, PNG.sync.write(template));
const match = await findImage(templatePath, screenPath, 0.02);
expect(match).toMatchObject({ x: 17, y: 12, width: 8, height: 8, score: 1 });
});
it("sends mail to a local SMTP server", async () => {
let received = "";
const server = net.createServer((socket) => {
socket.setEncoding("utf8");
socket.write("220 localhost ESMTP\r\n");
let buffer = "";
let dataMode = false;
socket.on("data", (chunk) => {
buffer += chunk;
const lines = buffer.split("\r\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (dataMode) {
if (line === ".") {
dataMode = false;
socket.write("250 queued\r\n");
} else received += `${line}\n`;
} else if (/^EHLO/i.test(line)) socket.write("250-localhost\r\n250 OK\r\n");
else if (/^DATA/i.test(line)) { dataMode = true; socket.write("354 end with dot\r\n"); }
else if (/^QUIT/i.test(line)) { socket.write("221 bye\r\n"); socket.end(); }
else socket.write("250 OK\r\n");
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
try {
const send = getHandler("Mail_Send");
expect(send).toBeTruthy();
await send!(context, node("Mail_Send"), {
host: "127.0.0.1", port: String(port), account: "robot@example.com",
to: "owner@example.com", subject: "RPA smoke", content: "mail delivered", is_ssl: "False"
}, component);
expect(received).toContain("RPA smoke");
expect(received).toContain("mail delivered");
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("creates and writes a Word document through COM", async () => {
if (process.platform !== "win32") return;
const directory = await tempDir();
const create = getHandler("Word_New");
const write = getHandler("Word_Write");
expect(create && write).toBeTruthy();
const handle = await create!(context, node("Word_New"), { filePath: directory, name: "smoke.docx" }, component);
await write!(context, node("Word_Write"), { objWord: handle, text: "RPA Word smoke" }, component);
const file = await readFile(path.join(directory, "smoke.docx"));
expect(file.byteLength).toBeGreaterThan(1000);
}, 30000);
});

View File

@ -0,0 +1,39 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import type { Catalog } from "../../shared/types";
import { hasCapability } from "../engine/registry";
import "./index";
const planned = [
"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",
"WebBrowser_CreateV3", "WebBrowser_GoUrl", "WebBrowser_Switch", "Element_Click_V6", "Element_SetValue_V6",
"Element_GetText_V5", "Element_Exists", "Element_SelectOperation_v1", "WebBrowser_RunJsInIeByTitle", "WebBrowser_SaveAs",
"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",
"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",
"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",
"Http_Get", "Http_Post", "Mail_Send", "Mail_UnionSend",
"PDF_GetPdfPageNum", "PDF_GetPdfText", "PDF_ConcatPdf", "Word_New", "Word_Write",
"Image_Screenshot", "Image_Find", "Image_Click"
];
const runtimeContainers = new Set([
"logic_control_if_else", "logic_control_count_for", "logic_control_array_for_v3", "logic_control_dictionary_for_v1",
"logic_control_while", "infinite_loop", "logic_control_try_catch"
]);
describe("planned capability audit", () => {
it("keeps every planned representative capability executable", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const missing = planned.filter((id) => {
const component = catalog.components.find((item) => item.id === id);
return !component || (!hasCapability(component.id) && !hasCapability(component.functionName) && !runtimeContainers.has(component.id));
});
expect(missing).toEqual([]);
});
});

View File

@ -0,0 +1,163 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
const execFileAsync = promisify(execFile);
const USER32 = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaUser32 {
[DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr hWnd, System.Text.StringBuilder text, int count);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
}
'@`;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
}
return fallback;
}
function number(args: Record<string, unknown>, keys: string[], fallback = 0): number {
const value = Number(text(args, keys, String(fallback)));
return Number.isFinite(value) ? value : fallback;
}
function point(value: unknown): [number, number] {
if (Array.isArray(value)) return [Number(value[0]) || 0, Number(value[1]) || 0];
try {
const parsed = JSON.parse(String(value || "[0,0]"));
return point(parsed);
} catch {
const values = String(value || "").split(/[ ,,]+/).map(Number);
return [values[0] || 0, values[1] || 0];
}
}
function encoded(command: string): string {
return Buffer.from(command, "utf16le").toString("base64");
}
export async function powershell(command: string, timeout = 30000): Promise<string> {
try {
const result = await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded(command)], {
windowsHide: true,
timeout,
maxBuffer: 4 * 1024 * 1024
});
return String(result.stdout || "").trim();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new RuntimeError("desktop_command_failed", `桌面操作失败:${detail}`, { detail });
}
}
export function psString(value: unknown): string {
return `'${String(value ?? "").replace(/'/g, "''")}'`;
}
const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const keys = text(args, ["keys", "key", "text", "value"]);
if (!keys) throw new RuntimeError("validation_error", "键盘输入为空", { nodeId: node.id, componentId: node.componentId });
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait(${psString(keys)})`);
return keys;
};
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
const button = text(args, ["side", "button"], "0").toLowerCase();
const down = button.includes("right") || button === "1" ? 0x0008 : 0x0002;
const up = button.includes("right") || button === "1" ? 0x0010 : 0x0004;
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y}); [RpaUser32]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaUser32]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = point(args.position ?? args.point ?? args.location ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${x},${y})`);
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await powershell(`${USER32}; [RpaUser32]::SetCursorPos(${sx},${sy}); [RpaUser32]::mouse_event(0x0002,0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaUser32]::SetCursorPos(${ex},${ey}); Start-Sleep -Milliseconds 80; [RpaUser32]::mouse_event(0x0004,0,0,0,[UIntPtr]::Zero)`);
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await powershell(`${USER32}; [RpaUser32]::mouse_event(0x0800,0,0,${amount},[UIntPtr]::Zero)`);
return amount;
};
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const result = await powershell(`${USER32}; $p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; [RpaUser32]::SetForegroundWindow($p.MainWindowHandle); $p.MainWindowTitle`);
return result;
};
const getActiveWindow: CapabilityHandler = async () => powershell(`${USER32}; $sb = New-Object System.Text.StringBuilder 512; [RpaUser32]::GetWindowText([RpaUser32]::GetForegroundWindow(),$sb,$sb.Capacity) | Out-Null; $sb.ToString()`);
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return (await powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; [bool]$p`)).toLowerCase() === "true";
};
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return powershell(`$p = Get-Process | Where-Object { $_.MainWindowTitle -and $_.MainWindowTitle -like ${psString(`*${selector}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口' }; $p.CloseMainWindow() | Out-Null; $true`);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {
const executable = text(args, ["executeable", "executable", "path"]);
if (!executable) throw new RuntimeError("validation_error", "应用路径为空", { nodeId: node.id, componentId: node.componentId });
const params = text(args, ["params", "arguments"]);
const cwd = text(args, ["cwd"]);
const result = await powershell(`$p = Start-Process -FilePath ${psString(executable)} -ArgumentList ${psString(params)} ${cwd ? `-WorkingDirectory ${psString(cwd)}` : ""} -PassThru; $p.Id`);
return Number(result) || result;
};
const clipboardGet: CapabilityHandler = async () => powershell("Get-Clipboard -Raw");
const clipboardSet: CapabilityHandler = async (_ctx, _node, args) => {
const content = text(args, ["text", "value"]);
await powershell(`Set-Clipboard -Value ${psString(content)}`);
return content;
};
const notice: CapabilityHandler = async (_ctx, _node, args) => {
const title = text(args, ["title"], "提醒");
const content = text(args, ["text", "message"]);
await powershell(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show(${psString(content)},${psString(title)}) | Out-Null`);
return true;
};
const executeCommand: CapabilityHandler = async (_ctx, node, args) => {
const command = text(args, ["action", "command"]);
if (!command) throw new RuntimeError("validation_error", "命令行为空", { nodeId: node.id, componentId: node.componentId });
return powershell(command, Math.max(1000, number(args, ["timeoutMs"], 120000)));
};
register(["Keyboard_Send_V1", "Keyboard_Send", "Keyboard_PressNew", "Keyboard_Press"], keyboardSend);
register(["Mouse_Click", "Mouse_ClickNew"], mouseClick);
register(["Mouse_MoveTo", "Mouse_Move"], mouseMove);
register(["Mouse_DragTo", "Mouse_Drag"], mouseDrag);
register(["Mouse_RollTo", "Mouse_Scroll"], mouseRoll);
register(["Window_SetActive", "Window_Activate"], setActiveWindow);
register(["Window_GetActive", "Window_GetActiveWindow"], getActiveWindow);
register(["Window_WindowExists", "Window_Exists"], windowExists);
register(["Window_CloseWindow", "Window_Close"], closeWindow);
register(["App_Run", "App_Start"], runApp);
register("Clipboard_GetText", clipboardGet);
register("Clipboard_SetText", clipboardSet);
register("Dialog_Notice", notice);
register("Dialog_MsgBox", notice);
register("System_ExecuteCommand", executeCommand);

View File

@ -0,0 +1,107 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./extended";
const directories: string[] = [];
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (componentId: string): FlowNode => ({
id: `extended_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-extended-"));
directories.push(directory);
return directory;
}
async function run(componentId: string, args: Record<string, unknown>): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTruthy();
return handler!(context, node(componentId), args, component);
}
afterEach(async () => {
await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("extended local capabilities", () => {
it("registers every audited component id", () => {
for (const id of [
"File_GetFileList_V1", "File_GetFolderList", "File_GetFolderList_V1", "File_MoveFolder",
"File_Write_V1", "File_Copy_V1", "File_Rename_V1", "File_ReadXml", "File_EmptyFolder",
"Bivartable_ReadDfShape", "Bivartable_SortDf", "Bivartable_DoSort", "Bivartable_DelDfColRow",
"Bivartable_ReadTable", "Excel_Cs_ReadCell", "Excel_GetRowsCount", "Excel_GetColumnsCount",
"Excel_Cs_GetEditRange"
]) expect(getHandler(id), id).toBeTruthy();
});
it("performs file listing, copy, rename, XML read, move, and empty operations", async () => {
const root = await temporaryDirectory();
const source = path.join(root, "source");
const nested = path.join(source, "nested");
await run("File_Write_V1", { path: path.join(source, "alpha.xml"), content: "<root>RPA</root>" });
await run("File_Write_V1", { path: path.join(nested, "beta.txt"), content: "beta" });
await expect(run("File_GetFileList_V1", { folder_path: source, recursive: "1", is_full_path: "0" }))
.resolves.toEqual(["alpha.xml", "beta.txt"]);
await expect(run("File_GetFolderList", { folder_path: source, recursive: "1" }))
.resolves.toEqual([nested]);
await expect(run("File_ReadXml", { path: path.join(source, "alpha.xml") }))
.resolves.toBe("<root>RPA</root>");
const copied = await run("File_Copy_V1", {
file_type: "file", file_path: path.join(source, "alpha.xml"), target_path: root,
rename: "1", name: "copied.xml", action: "1"
}) as string;
await expect(readFile(copied, "utf8")).resolves.toContain("RPA");
const renamed = await run("File_Rename_V1", { file_type: "file", file_path: copied, name: "renamed.xml", action: "1" }) as string;
await expect(readFile(renamed, "utf8")).resolves.toContain("RPA");
const moved = path.join(root, "moved");
await run("File_MoveFolder", { pathSrc: nested, pathDest: moved, overwrite: "True" });
await expect(readFile(path.join(moved, "beta.txt"), "utf8")).resolves.toBe("beta");
await run("File_EmptyFolder", { folder_path: moved });
await expect(readdir(moved)).resolves.toEqual([]);
});
it("sorts, measures, deletes, and selects two-dimensional data", async () => {
const input = [["name", "score"], ["B", 2], ["A", 3], ["C", 1]];
await expect(run("Bivartable_ReadDfShape", { df: input })).resolves.toEqual([4, 2]);
await expect(run("Bivartable_SortDf", { df: input, name: "score", sort: "True" }))
.resolves.toEqual([["name", "score"], ["C", 1], ["B", 2], ["A", 3]]);
await expect(run("Bivartable_DoSort", { df: [[3], [1], [2]], sortby: "1", basceeding: "False" }))
.resolves.toEqual([[3], [2], [1]]);
await expect(run("Bivartable_DelDfColRow", { df: input, delRange: "col", delType: "name", del_col_name_list: ["score"] }))
.resolves.toEqual([["name"], ["B"], ["A"], ["C"]]);
await expect(run("Bivartable_ReadTable", { df: input, start_row: "2", end_row: "3", start_column: "A", end_column: "B" }))
.resolves.toEqual([["B", 2], ["A", 3]]);
});
it("reads Excel cells, dimensions, and the edited range", async () => {
const root = await temporaryDirectory();
const filePath = path.join(root, "extended.xlsx");
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet("Data");
sheet.getCell("B2").value = "RPA";
sheet.getCell("D5").value = 42;
await workbook.xlsx.writeFile(filePath);
await expect(run("Excel_Cs_ReadCell", { workbook: filePath, sheet: "Data", column: "B", row: "2" })).resolves.toBe("RPA");
await expect(run("Excel_GetRowsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(5);
await expect(run("Excel_GetColumnsCount", { objExcel: filePath, sheet: "Data" })).resolves.toBe(4);
await expect(run("Excel_Cs_GetEditRange", { workbook: { path: filePath }, sheet: "Data" })).resolves.toEqual({
startRow: 2, startColumn: 2, endRow: 5, endColumn: 4, range: "B2:D5"
});
});
});

View File

@ -0,0 +1,284 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import ExcelJS from "exceljs";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
type Table = unknown[][];
function value(args: Record<string, unknown>, keys: string[], fallback?: unknown): unknown {
for (const key of keys) {
const candidate = args[key];
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(candidate: unknown, fallback = false): boolean {
if (candidate === undefined || candidate === null || candidate === "") return fallback;
return [true, 1, "1", "true", "True", "是", "开启"].includes(candidate as never);
}
function requiredPath(candidate: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(candidate ?? "").trim();
if (!result) {
throw new RuntimeError("validation_error", `${label}为空`, {
nodeId,
componentId,
recommendation: `请配置${label}`
});
}
return path.resolve(result);
}
function list(candidate: unknown): unknown[] {
if (Array.isArray(candidate)) return candidate;
if (candidate === undefined || candidate === null || candidate === "") return [];
if (typeof candidate === "string") {
try {
const parsed = JSON.parse(candidate);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return candidate.split(/[,,\n]/).map((item) => item.trim()).filter(Boolean);
}
}
return [candidate];
}
function table(candidate: unknown): Table {
const parsed = typeof candidate === "string"
? (() => {
try { return JSON.parse(candidate); } catch { return candidate; }
})()
: candidate;
if (!Array.isArray(parsed)) return parsed === undefined || parsed === null ? [] : [[parsed]];
return parsed.map((row) => Array.isArray(row) ? [...row] : [row]);
}
function columnNumber(candidate: unknown): number {
const source = String(candidate ?? "A").trim().toUpperCase();
if (/^\d+$/.test(source)) return Math.max(1, Number(source));
let result = 0;
for (const character of source) result = result * 26 + character.charCodeAt(0) - 64;
return Math.max(1, result);
}
function compare(left: unknown, right: unknown): number {
if (typeof left === "number" && typeof right === "number") return left - right;
return String(left ?? "").localeCompare(String(right ?? ""), "zh-CN", { numeric: true });
}
async function ensureAbsentOrRemove(target: string, action: unknown): Promise<boolean> {
try {
await fs.access(target);
} catch {
return true;
}
const mode = String(action ?? "1");
if (mode === "2" || mode.toLowerCase() === "skip") return false;
if (mode === "3" || mode.toLowerCase() === "error") throw new Error(`目标已存在:${target}`);
await fs.rm(target, { recursive: true, force: true });
return true;
}
async function collectEntries(
root: string,
kind: "file" | "directory",
recursive: boolean,
keyword: string,
ignore: string
): Promise<string[]> {
const results: string[] = [];
async function walk(folder: string): Promise<void> {
const entries = await fs.readdir(folder, { withFileTypes: true });
for (const entry of entries) {
const absolute = path.join(folder, entry.name);
if (entry.isDirectory()) {
if (kind === "directory" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
if (recursive) await walk(absolute);
} else if (kind === "file" && (!keyword || entry.name.includes(keyword)) && (!ignore || !entry.name.includes(ignore))) {
results.push(absolute);
}
}
}
await walk(root);
return results;
}
register("File_GetFileList_V1", async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const fullPath = bool(args.is_full_path, true);
const results = await collectEntries(root, "file", bool(args.recursive), text(args, ["key_word"]), text(args, ["ignore"]));
const sortDescending = ["2", "desc", "descending"].includes(String(args.sort_type ?? "1").toLowerCase());
results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }) * (sortDescending ? -1 : 1));
return fullPath ? results : results.map((item) => path.basename(item));
});
const getFolderList: CapabilityHandler = async (_ctx, node, args) => {
const root = requiredPath(value(args, ["folder_path"]), "文件夹", node.id, node.componentId);
const results = await collectEntries(root, "directory", bool(args.recursive), "", text(args, ["ignore"]));
return results.sort((left, right) => left.localeCompare(right, "zh-CN", { numeric: true }));
};
register(["File_GetFolderList", "File_GetFolderList_V1"], getFolderList);
register("File_MoveFolder", async (_ctx, node, args) => {
const source = requiredPath(value(args, ["pathSrc"]), "文件夹路径", node.id, node.componentId);
const target = requiredPath(value(args, ["pathDest"]), "目的地路径", node.id, node.componentId);
if (bool(args.overwrite)) await fs.rm(target, { recursive: true, force: true });
await fs.mkdir(path.dirname(target), { recursive: true });
try {
await fs.rename(source, target);
} catch {
await fs.cp(source, target, { recursive: true, force: bool(args.overwrite) });
await fs.rm(source, { recursive: true, force: true });
}
return target;
});
register("File_Write_V1", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "文件路径", node.id, node.componentId);
await fs.mkdir(path.dirname(target), { recursive: true });
const content = String(args.content ?? "");
if (bool(args.append)) await fs.appendFile(target, content, "utf8");
else await fs.writeFile(target, content, "utf8");
return target;
});
register("File_Copy_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), isFolder ? "待复制文件夹" : "待复制文件", node.id, node.componentId);
const destination = requiredPath(value(args, ["target_path"]), "复制目标", node.id, node.componentId);
const newName = bool(args.rename) && text(args, ["name"]) ? text(args, ["name"]) : path.basename(source);
const target = path.join(destination, newName);
if (!await ensureAbsentOrRemove(target, args.action)) return target;
await fs.mkdir(destination, { recursive: true });
if (isFolder) await fs.cp(source, target, { recursive: true, force: true });
else await fs.copyFile(source, target);
return target;
});
register("File_Rename_V1", async (_ctx, node, args) => {
const isFolder = text(args, ["file_type"], "file").toLowerCase() === "folder";
const source = requiredPath(value(args, [isFolder ? "folder_path" : "file_path"]), "待重命名路径", node.id, node.componentId);
const name = text(args, ["name"]).trim();
if (!name || path.basename(name) !== name) throw new Error("新名称必须是单独的文件或文件夹名称");
const target = path.join(path.dirname(source), name);
if (!await ensureAbsentOrRemove(target, args.action)) return source;
await fs.rename(source, target);
return target;
});
register("File_ReadXml", async (_ctx, node, args) => {
const target = requiredPath(value(args, ["path"]), "XML文件路径", node.id, node.componentId);
const buffer = await fs.readFile(target);
const encoding = text(args, ["encode"], "utf-8").toLowerCase().replace("_", "-");
if (encoding === "utf-8" || encoding === "utf8" || encoding === "auto") return buffer.toString("utf8");
return new TextDecoder(encoding).decode(buffer);
});
register("File_EmptyFolder", async (_ctx, node, args) => {
const folder = requiredPath(value(args, ["folder_path"]), "待清空文件夹", node.id, node.componentId);
const entries = await fs.readdir(folder);
await Promise.all(entries.map((entry) => fs.rm(path.join(folder, entry), { recursive: true, force: true })));
return true;
});
register("Bivartable_ReadDfShape", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
return [rows.length, rows.reduce((maximum, row) => Math.max(maximum, row.length), 0)];
});
function sortTable(args: Record<string, unknown>): Table {
const rows = table(value(args, ["df"]));
if (rows.length < 2) return rows;
const requested = text(args, ["name", "sortby"]).split(/[,,]/)[0].trim();
const headerIndex = requested ? rows[0].findIndex((cell) => String(cell) === requested) : -1;
const index = headerIndex >= 0 ? headerIndex : columnNumber(requested || "1") - 1;
const ascending = bool(value(args, ["sort", "basceeding"]), true);
const hasHeader = headerIndex >= 0;
const body = (hasHeader ? rows.slice(1) : rows).sort((left, right) => compare(left[index], right[index]) * (ascending ? 1 : -1));
return hasHeader ? [rows[0], ...body] : body;
}
register(["Bivartable_SortDf", "Bivartable_DoSort"], (_ctx, _node, args) => sortTable(args));
register("Bivartable_DelDfColRow", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const deletingColumns = text(args, ["delRange"], "col").toLowerCase().includes("col");
const raw = deletingColumns
? value(args, [text(args, ["delType"]) === "name" ? "del_col_name_list" : "del_col_num_list"])
: value(args, [text(args, ["delType"]) === "name" ? "del_row_name_list" : "del_row_num_list"]);
const requested = list(raw);
if (deletingColumns) {
const indexes = new Set(requested.map((item) => {
const byName = rows[0]?.findIndex((cell) => String(cell) === String(item)) ?? -1;
return text(args, ["delType"]) === "name" ? byName : columnNumber(item) - 1;
}).filter((index) => index >= 0));
return rows.map((row) => row.filter((_cell, index) => !indexes.has(index)));
}
const indexes = new Set(requested.map((item) => Math.max(0, Number(item) - 1)));
return rows.filter((_row, index) => !indexes.has(index));
});
register("Bivartable_ReadTable", (_ctx, _node, args) => {
const rows = table(value(args, ["df"]));
const startRow = Math.max(1, Number(args.start_row ?? args.row ?? 1)) - 1;
const endRow = Math.max(startRow + 1, Number(args.end_row ?? args.row ?? rows.length));
const startColumn = columnNumber(args.start_column ?? args.column ?? "A") - 1;
const endColumn = Math.max(startColumn + 1, columnNumber(args.end_column ?? args.column ?? String(rows[0]?.length || 1)));
const selection = rows.slice(startRow, endRow).map((row) => row.slice(startColumn, endColumn));
if (selection.length === 1 && selection[0].length === 1) return selection[0][0];
return selection;
});
function workbookPath(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "path", "file"]);
const candidate = raw && typeof raw === "object" ? (raw as { path?: unknown }).path : raw;
return requiredPath(candidate, "Excel文件路径", nodeId, componentId);
}
async function loadSheet(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<ExcelJS.Worksheet> {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(workbookPath(args, nodeId, componentId));
const name = text(args, ["sheet", "sheetName"]);
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function excelValue(candidate: ExcelJS.CellValue): unknown {
if (candidate && typeof candidate === "object" && "result" in candidate) return candidate.result;
if (candidate && typeof candidate === "object" && "text" in candidate) return candidate.text;
return candidate ?? "";
}
register("Excel_Cs_ReadCell", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
return excelValue(sheet.getCell(`${text(args, ["column"], "A")}${text(args, ["row"], "1")}`).value);
});
register("Excel_GetRowsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).rowCount);
register("Excel_GetColumnsCount", async (_ctx, node, args) => (await loadSheet(args, node.id, node.componentId)).columnCount);
register("Excel_Cs_GetEditRange", async (_ctx, node, args) => {
const sheet = await loadSheet(args, node.id, node.componentId);
let startRow = Number.POSITIVE_INFINITY;
let startColumn = Number.POSITIVE_INFINITY;
let endRow = 0;
let endColumn = 0;
sheet.eachRow({ includeEmpty: false }, (row, rowNumber) => {
row.eachCell({ includeEmpty: false }, (_cell, columnNumberValue) => {
startRow = Math.min(startRow, rowNumber);
startColumn = Math.min(startColumn, columnNumberValue);
endRow = Math.max(endRow, rowNumber);
endColumn = Math.max(endColumn, columnNumberValue);
});
});
if (!endRow) return { startRow: 0, startColumn: 0, endRow: 0, endColumn: 0, range: "" };
const range = `${sheet.getColumn(startColumn).letter}${startRow}:${sheet.getColumn(endColumn).letter}${endRow}`;
return { startRow, startColumn, endRow, endColumn, range };
});

View File

@ -3,5 +3,12 @@ import "./files";
import "./http";
import "./data";
import "./browser";
import "./office";
import "./desktop";
import "./pdf";
import "./word";
import "./message";
import "./vision";
import "./extended";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";

View File

@ -0,0 +1,31 @@
import nodemailer from "nodemailer";
import { register } from "../engine/registry";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function list(value: string): string[] {
return value.split(/[;,,;\s]+/).map((item) => item.trim()).filter(Boolean);
}
register(["Mail_Send", "Mail_UnionSend"], async (_ctx, node, args) => {
const account = text(args, ["account", "from", "account_obj"]);
const password = text(args, ["password", "pass"]);
const host = text(args, ["host", "smtp"]);
if (!host || !account) throw new Error(`邮件配置不完整(${node.componentId})`);
const port = Number(text(args, ["port"], "25"));
const secure = ["true", "1", "是", "True"].includes(text(args, ["is_ssl"], "False"));
const transporter = nodemailer.createTransport({ host, port, secure, auth: password ? { user: account, pass: password } : undefined });
const result = await transporter.sendMail({
from: account,
to: list(text(args, ["to", "receiver"])),
cc: list(text(args, ["Cc", "cc"])),
bcc: list(text(args, ["Bcc", "bcc"])),
subject: text(args, ["subject"]),
text: text(args, ["content", "body"]),
attachments: text(args, ["attachment"]) ? [{ path: text(args, ["attachment"]) }] : undefined
});
return { messageId: result.messageId, accepted: result.accepted };
});

View File

@ -0,0 +1,222 @@
import ExcelJS from "exceljs";
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
interface ExcelHandle {
id: string;
path: string;
}
const workbooks = new Map<string, ExcelJS.Workbook>();
function value(args: Record<string, unknown>, keys: string[], fallback = ""): unknown {
for (const key of keys) {
if (args[key] !== undefined && args[key] !== null && args[key] !== "") return args[key];
}
return fallback;
}
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
return String(value(args, keys, fallback));
}
function bool(valueToRead: unknown, fallback = false): boolean {
if (valueToRead === undefined || valueToRead === null || valueToRead === "") return fallback;
return [true, "true", "1", 1, "是", "开启"].includes(valueToRead as never);
}
function required(valueToRead: unknown, label: string, nodeId: string, componentId: string): string {
const result = String(valueToRead || "").trim();
if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` });
return result;
}
function newHandle(filePath: string, workbook: ExcelJS.Workbook): ExcelHandle {
const handle = { id: `excel_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, path: filePath };
workbooks.set(handle.id, workbook);
return handle;
}
async function resolveWorkbook(args: Record<string, unknown>, nodeId: string, componentId: string): Promise<{ handle: ExcelHandle; workbook: ExcelJS.Workbook }> {
const raw = value(args, ["workbook", "objExcel", "objExcelWorkBook", "file", "path"]);
const id = typeof raw === "object" && raw !== null ? String((raw as Partial<ExcelHandle>).id || "") : String(raw || "");
const existing = id ? workbooks.get(id) : undefined;
if (existing) return { handle: { id, path: String((raw as Partial<ExcelHandle>).path || "") }, workbook: existing };
const filePath = required(typeof raw === "object" ? (raw as Partial<ExcelHandle>).path : raw, "Excel文件路径", nodeId, componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(filePath);
return { handle: newHandle(filePath, workbook), workbook };
}
function sheetOf(workbook: ExcelJS.Workbook, name: string): ExcelJS.Worksheet {
const sheet = name ? workbook.getWorksheet(name) : workbook.worksheets[0];
if (!sheet) throw new Error(`Sheet 不存在:${name || "第一个 Sheet"}`);
return sheet;
}
function parseRange(range: string, sheet: ExcelJS.Worksheet): { start: string; end?: string } {
if (range.trim()) {
const [start, end] = range.split(":").map((item) => item.trim());
return { start, end };
}
const lastRow = Math.max(1, sheet.rowCount);
const lastColumn = Math.max(1, sheet.columnCount);
return { start: "A1", end: `${sheet.getColumn(lastColumn).letter}${lastRow}` };
}
function cellValue(valueToRead: ExcelJS.CellValue): unknown {
if (valueToRead && typeof valueToRead === "object" && "text" in valueToRead) return valueToRead.text;
if (valueToRead && typeof valueToRead === "object" && "result" in valueToRead) return valueToRead.result;
return valueToRead ?? "";
}
function tableFromSheet(sheet: ExcelJS.Worksheet, range: string): unknown[][] {
const target = parseRange(range, sheet);
const startRow = Number(sheet.getCell(target.start).row);
const endRow = target.end ? Number(sheet.getCell(target.end).row) : sheet.rowCount;
const rows = sheet.getRows(startRow, Math.max(1, endRow - startRow + 1)) || [];
const startColumn = Number(sheet.getCell(target.start).col);
const endColumn = target.end ? Number(sheet.getCell(target.end).col) : sheet.columnCount;
return rows.map((row) => {
const values: unknown[] = [];
for (let column = startColumn; column <= endColumn; column += 1) values.push(cellValue(row.getCell(column).value));
return values;
});
}
function rowsFrom(valueToRead: unknown): unknown[][] {
if (Array.isArray(valueToRead)) {
return valueToRead.map((row) => Array.isArray(row) ? row : [row]);
}
if (typeof valueToRead === "string") {
try {
return rowsFrom(JSON.parse(valueToRead));
} catch {
return [[valueToRead]];
}
}
return [[valueToRead]];
}
const newExcel: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(value(args, ["path", "folder"]), "Excel目录", node.id, node.componentId);
const name = text(args, ["name"], "新建工作簿.xlsx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.xlsx`);
if (!bool(value(args, ["override"]), true)) {
try {
await fs.access(filePath);
throw new RuntimeError("validation_error", `文件已存在:${filePath}`, { nodeId: node.id, componentId: node.componentId, recommendation: "开启覆盖同名文件,或修改文件名称" });
} catch (error) {
if (error instanceof RuntimeError) throw error;
}
}
const workbook = new ExcelJS.Workbook();
workbook.addWorksheet("Sheet1");
await fs.mkdir(path.dirname(filePath), { recursive: true });
await workbook.xlsx.writeFile(filePath);
return newHandle(filePath, workbook);
};
const openExcel: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(value(args, ["file", "path"]), "Excel文件", node.id, node.componentId);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.readFile(path.resolve(filePath));
return newHandle(path.resolve(filePath), workbook);
};
const readExcel: CapabilityHandler = async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
return tableFromSheet(sheet, text(args, ["range", "excelRng"]));
};
const writeCell: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const address = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
sheet.getCell(address).value = value(args, ["content", "value"]) as ExcelJS.CellValue;
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const writeRange: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const start = `${text(args, ["column"], "A")}${text(args, ["row"], "1")}`;
const startCell = sheet.getCell(start);
const startRow = startCell.row;
const startColumn = startCell.col;
for (const [rowIndex, row] of rowsFrom(value(args, ["contents", "table"])).entries()) {
for (const [columnIndex, cell] of row.entries()) sheet.getCell(startRow + rowIndex, startColumn + columnIndex).value = cell as ExcelJS.CellValue;
}
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
};
const saveExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
await workbook.xlsx.writeFile(handle.path);
return handle.path;
};
const saveAsExcel: CapabilityHandler = async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const target = required(value(args, ["path", "file", "savePath"]), "另存为路径", node.id, node.componentId);
handle.path = path.resolve(target);
await fs.mkdir(path.dirname(handle.path), { recursive: true });
await workbook.xlsx.writeFile(handle.path);
return handle;
};
register(["Excel_Cs_NewV5", "Excel_Cs_New", "Excel_New"], newExcel);
register(["Excel_Cs_OpenV4", "Excel_Cs_Open", "Excel_Open"], openExcel);
register(["Excel_Cs_ReadContentV2", "Excel_Cs_ReadRangeV2", "Excel_ReadRange"], readExcel);
register(["Excel_Cs_WriteCell", "Excel_WriteCell"], writeCell);
register(["Excel_Cs_WriteRange", "Excel_WriteRange"], writeRange);
register(["Excel_Save", "Excel_Cs_Save", "Excel_SaveV2"], saveExcel);
register(["Excel_Cs_SaveAs", "Excel_SaveAs"], saveAsExcel);
register(["Excel_GetSheetsName", "Excel_GetSheetNames"], async (_ctx, node, args) => {
const { workbook } = await resolveWorkbook(args, node.id, node.componentId);
return workbook.worksheets.map((sheet) => sheet.name);
});
register(["Excel_Cs_Rename", "Excel_RenameSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const oldName = required(value(args, ["sheet", "oldName"]), "原 Sheet 名称", node.id, node.componentId);
const newName = required(value(args, ["newName", "name", "sheetName"]), "新 Sheet 名称", node.id, node.componentId);
const sheet = sheetOf(workbook, oldName);
sheet.name = newName;
await workbook.xlsx.writeFile(handle.path);
return handle;
});
register(["Excel_Cs_Insert", "Excel_InsertSheet"], async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const name = required(value(args, ["name", "sheetName"]), "Sheet名称", node.id, node.componentId);
if (workbook.getWorksheet(name)) throw new RuntimeError("validation_error", `Sheet 已存在:${name}`, { nodeId: node.id, componentId: node.componentId });
workbook.addWorksheet(name);
if (bool(value(args, ["save"]), false)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertRow", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const rows = rowsFrom(value(args, ["contents"]));
const inserts = Array.from({ length: count }, (_, rowIndex) => rows[rowIndex] || []);
sheet.spliceRows(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});
register("Excel_Cs_InsertColumn", async (_ctx, node, args) => {
const { handle, workbook } = await resolveWorkbook(args, node.id, node.componentId);
const sheet = sheetOf(workbook, text(args, ["sheet", "sheetName"]));
const index = Math.max(1, Number(value(args, ["index"], "1")) || 1);
const count = Math.max(1, Number(value(args, ["count"], "1")) || 1);
const values = rowsFrom(value(args, ["contents"])).flat();
const inserts = Array.from({ length: count }, (_, columnIndex) => [values[columnIndex] ?? ""]);
sheet.spliceColumns(index, 0, ...inserts);
if (bool(value(args, ["save"]), true)) await workbook.xlsx.writeFile(handle.path);
return handle;
});

View File

@ -0,0 +1,62 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function required(value: string, label: string, nodeId: string, componentId: string): string {
if (!value.trim()) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId });
return path.resolve(value);
}
const pageCount: CapabilityHandler = async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const document = await PDFDocument.load(await fs.readFile(filePath), { ignoreEncryption: true });
return document.getPageCount();
};
const concat: CapabilityHandler = async (_ctx, node, args) => {
const folder = required(text(args, ["newPdfPath", "folder", "path"]), "输出目录", node.id, node.componentId);
const name = text(args, ["newPdfName", "name"], "合并结果.pdf");
const output = path.join(folder, path.extname(name) ? name : `${name}.pdf`);
const rawList = text(args, ["pdfList", "files"]);
let files: string[] = [];
try {
const parsed = JSON.parse(rawList);
files = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)];
} catch {
files = rawList.split(/[\r\n;,]+/).map((item) => item.trim()).filter(Boolean);
}
if (!files.length) throw new RuntimeError("validation_error", "待合并 PDF 列表为空", { nodeId: node.id, componentId: node.componentId });
const target = await PDFDocument.create();
for (const file of files) {
const source = await PDFDocument.load(await fs.readFile(path.resolve(file)), { ignoreEncryption: true });
const pages = await target.copyPages(source, source.getPageIndices());
pages.forEach((page) => target.addPage(page));
}
await fs.mkdir(path.dirname(output), { recursive: true });
await fs.writeFile(output, await target.save());
return output;
};
register(["PDF_GetPdfPageNum", "PDF_GetPageCount"], pageCount);
register("PDF_ConcatPdf", concat);
register("PDF_GetPdfText", async (_ctx, node, args) => {
const filePath = required(text(args, ["fileInPath", "path", "file"]), "PDF文件路径", node.id, node.componentId);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const document = await pdfjs.getDocument({ data: new Uint8Array(await fs.readFile(filePath)) }).promise;
const pages = text(args, ["pdfNum", "page"]);
const indices = pages ? pages.split(/[ ,,;;]+/).map((item) => Number(item)).filter((item) => item > 0) : Array.from({ length: document.numPages }, (_, index) => index + 1);
const chunks: string[] = [];
for (const index of indices) {
const page = await document.getPage(index);
const content = await page.getTextContent();
chunks.push(content.items.map((item) => ("str" in item ? item.str : "")).join(""));
}
return chunks.join("\n");
});

View File

@ -0,0 +1,97 @@
import path from "node:path";
import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { powershell, psString } from "./desktop";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
register("Image_Screenshot", async (_ctx, node, args) => {
const folder = text(args, ["outImagePath", "folder"]);
const name = text(args, ["outImageName", "name"], `screenshot_${Date.now()}.png`);
const output = path.resolve(text(args, ["image_path"], folder ? path.join(folder, name) : name));
await fs.mkdir(path.dirname(output), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(output)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return output;
});
interface MatchResult { x: number; y: number; width: number; height: number; score: number; }
async function captureScreen(target: string): Promise<string> {
await fs.mkdir(path.dirname(target), { recursive: true });
await powershell(`Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $bounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bitmap = New-Object System.Drawing.Bitmap $bounds.Width,$bounds.Height; $graphics = [System.Drawing.Graphics]::FromImage($bitmap); $graphics.CopyFromScreen($bounds.Location,[System.Drawing.Point]::Empty,$bounds.Size); $bitmap.Save(${psString(target)},[System.Drawing.Imaging.ImageFormat]::Png); $graphics.Dispose(); $bitmap.Dispose()`);
return target;
}
function pixelDifference(screen: PNG, template: PNG, offsetX: number, offsetY: number, sampleStep: number, cutoff: number): number {
let difference = 0;
let samples = 0;
for (let y = 0; y < template.height; y += sampleStep) {
for (let x = 0; x < template.width; x += sampleStep) {
const sourceIndex = ((offsetY + y) * screen.width + offsetX + x) * 4;
const templateIndex = (y * template.width + x) * 4;
difference += Math.abs(screen.data[sourceIndex] - template.data[templateIndex]);
difference += Math.abs(screen.data[sourceIndex + 1] - template.data[templateIndex + 1]);
difference += Math.abs(screen.data[sourceIndex + 2] - template.data[templateIndex + 2]);
samples += 3;
if (difference > cutoff * samples) return difference / (samples * 255);
}
}
return samples ? difference / (samples * 255) : 1;
}
export async function findImage(templatePath: string, screenshotPath: string, accuracy: number): Promise<MatchResult | null> {
const [screenBuffer, templateBuffer] = await Promise.all([fs.readFile(screenshotPath), fs.readFile(templatePath)]);
const screen = PNG.sync.read(screenBuffer);
const template = PNG.sync.read(templateBuffer);
if (template.width > screen.width || template.height > screen.height) return null;
const maximumDifference = Math.max(0.01, Math.min(0.95, accuracy || 0.2));
const sampleStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 18));
const scanStep = Math.max(1, Math.floor(Math.min(template.width, template.height) / 12));
let best: MatchResult | null = null;
for (let y = 0; y <= screen.height - template.height; y += scanStep) {
for (let x = 0; x <= screen.width - template.width; x += scanStep) {
const difference = pixelDifference(screen, template, x, y, sampleStep, best ? 1 - best.score : maximumDifference);
const score = 1 - difference;
if (!best || score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
if (!best || 1 - best.score > maximumDifference) return null;
const refine = Math.max(1, scanStep);
const minX = Math.max(0, best.x - refine);
const maxX = Math.min(screen.width - template.width, best.x + refine);
const minY = Math.max(0, best.y - refine);
const maxY = Math.min(screen.height - template.height, best.y + refine);
for (let y = minY; y <= maxY; y += 1) {
for (let x = minX; x <= maxX; x += 1) {
const difference = pixelDifference(screen, template, x, y, sampleStep, 1 - best.score);
const score = 1 - difference;
if (score > best.score) best = { x, y, width: template.width, height: template.height, score };
}
}
return best;
}
async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId: string): Promise<MatchResult> {
const rawTemplate = text(args, ["image", "image_path", "template"]);
if (!rawTemplate.trim()) throw new RuntimeError("validation_error", "模板图片路径为空", { nodeId });
const template = path.resolve(rawTemplate);
const screenshot = path.join(evidenceDir, `${nodeId}_screen.png`);
await captureScreen(screenshot);
const result = await findImage(template, screenshot, Number(text(args, ["accuracy"], "0.2")));
if (!result) throw new RuntimeError("not_found", "屏幕中未找到目标图像", { nodeId, recommendation: "重新截取模板,或调低匹配精度" });
return result;
}
register(["Image_Find", "Image_Find_V1"], async (ctx, node, args) => locate(args, ctx.evidenceDir, node.id));
register(["Image_Click", "Image_Click_V1", "Image_Click_V2", "Image_Click_V3"], async (ctx, node, args) => {
const result = await locate(args, ctx.evidenceDir, node.id);
const x = Math.round(result.x + result.width / 2);
const y = Math.round(result.y + result.height / 2);
await powershell(`Add-Type @'\nusing System; using System.Runtime.InteropServices; public static class Clicker { [DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint x,uint y,uint d,UIntPtr e); }\n'@; [Clicker]::SetCursorPos(${x},${y}); [Clicker]::mouse_event(2,0,0,0,[UIntPtr]::Zero); [Clicker]::mouse_event(4,0,0,0,[UIntPtr]::Zero)`);
return { ...result, click: { x, y } };
});

View File

@ -0,0 +1,44 @@
import path from "node:path";
import { register } from "../engine/registry";
import { powershell, psString } from "./desktop";
interface WordHandle { path: string; }
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) if (args[key] !== undefined && args[key] !== null && args[key] !== "") return String(args[key]);
return fallback;
}
function handlePath(args: Record<string, unknown>): string {
const raw = args.objWord ?? args.filePath ?? args.path ?? args.file;
if (typeof raw === "object" && raw !== null && "path" in raw) return path.resolve(String((raw as WordHandle).path));
return path.resolve(String(raw || ""));
}
function ensure(value: string, label: string): string {
if (!value.trim()) throw new Error(`${label}为空`);
return value;
}
function wordScript(body: string): string {
return `$word = $null; $doc = $null; try { $word = New-Object -ComObject Word.Application; $word.Visible = $false; $word.DisplayAlerts = 0; ${body} } finally { if ($doc -ne $null) { try { $doc.Close() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($doc) } catch {} }; if ($word -ne $null) { try { $word.Quit() } catch {} ; try { [void][Runtime.InteropServices.Marshal]::ReleaseComObject($word) } catch {} }; [GC]::Collect(); [GC]::WaitForPendingFinalizers() }`;
}
register("Word_New", async (_ctx, _node, args) => {
const folder = ensure(text(args, ["filePath", "folder", "path"]), "Word目录");
const name = text(args, ["name"], "新建文档.docx");
const filePath = path.resolve(folder, path.extname(name) ? name : `${name}.docx`);
await powershell(wordScript(`$doc = $word.Documents.Add(); $doc.SaveAs2(${psString(filePath)}); ${psString(filePath)}`));
return { path: filePath } satisfies WordHandle;
});
register("Word_Open", async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
register("Word_Write", async (_ctx, _node, args) => {
const filePath = ensure(handlePath(args), "Word文件路径");
const content = text(args, ["text", "content"]);
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $range = $doc.Content; $range.Collapse(0); $range.InsertAfter(${psString(content)}); $doc.Save()`));
return { path: filePath } satisfies WordHandle;
});
register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));

View File

@ -6,6 +6,16 @@ import { hasCapability } from "./engine/registry";
let cached: Catalog | null = null;
const RUNTIME_CONTAINERS = new Set([
"logic_control_if_else",
"logic_control_count_for",
"logic_control_array_for_v3",
"logic_control_dictionary_for_v1",
"logic_control_while",
"infinite_loop",
"logic_control_try_catch"
]);
function resolveCatalogPath(): string {
const candidates = [
process.env.RPA_CATALOG_PATH,
@ -29,7 +39,7 @@ export function loadCatalog(): Catalog {
const catalog = JSON.parse(raw) as Catalog;
catalog.components = catalog.components.map((component: CatalogComponent) => ({
...component,
implemented: hasCapability(component.id) || hasCapability(component.functionName)
implemented: RUNTIME_CONTAINERS.has(component.id) || hasCapability(component.id) || hasCapability(component.functionName)
}));
cached = catalog;
return catalog;

View File

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition } from "../../shared/types";
import { register } from "./registry";
import { runFlow } from "./runtime";
const components = new Map<string, CatalogComponent>();
function component(id: string): CatalogComponent {
const value = {
id, name: id, kind: "vendor", group: "data", categories: [], categorySignature: "", description: "",
messageTemplate: "", baseType: id, functionName: id, functionCalls: [], version: "", tags: [], platformTag: null,
icon: "data", risk: "low", fields: [], output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] }, slots: [], implemented: true
} as CatalogComponent;
components.set(id, value);
return value;
}
for (const id of ["Test.Start", "Test.Fail", "Test.Normal", "Test.Recover"]) component(id);
register("Test.Start", () => true);
register("Test.Fail", () => { throw new Error("expected failure"); });
register("Test.Normal", (ctx) => { ctx.vars.normalRan = true; });
register("Test.Recover", (ctx) => { ctx.vars.recovered = true; });
describe("runtime error route", () => {
it("uses the error handle after failure and does not execute the normal branch", async () => {
const now = new Date().toISOString();
const flow: FlowDefinition = {
schemaVersion: 1, id: "error-route", name: "error route", variables: [],
nodes: [
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
{ id: "fail", componentId: "Test.Fail", position: { x: 0, y: 100 }, values: {}, options: { errorRouteEnabled: true } },
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 200 }, values: {}, options: {} },
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 100 }, values: {}, options: {} }
],
edges: [
{ id: "e1", source: "start", target: "fail" },
{ id: "e2", source: "fail", target: "normal" },
{ id: "e3", source: "fail", sourceHandle: "error", target: "recover" }
],
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
};
const result = await runFlow(flow, {
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
});
expect(result.status).toBe("succeeded");
expect(result.variables.recovered).toBe(true);
expect(result.variables.normalRan).toBeUndefined();
expect(result.variables.error).toMatchObject({ reason: "expected failure" });
});
it("uses the normal handle after success and leaves error and disconnected nodes untouched", async () => {
const now = new Date().toISOString();
const flow: FlowDefinition = {
schemaVersion: 1, id: "normal-route", name: "normal route", variables: [],
nodes: [
{ id: "start", componentId: "Test.Start", position: { x: 0, y: 0 }, values: {}, options: {} },
{ id: "normal", componentId: "Test.Normal", position: { x: 0, y: 100 }, values: {}, options: {} },
{ id: "recover", componentId: "Test.Recover", position: { x: 200, y: 0 }, values: {}, options: {} },
{ id: "orphan", componentId: "Test.Recover", position: { x: 400, y: 400 }, values: {}, options: {} }
],
edges: [
{ id: "e1", source: "start", target: "normal" },
{ id: "e2", source: "start", sourceHandle: "error", target: "recover" }
],
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
};
const result = await runFlow(flow, {
runId: "test", evidenceDir: ".", signal: new AbortController().signal,
emit: () => {}, log: () => {}, componentOf: (node) => components.get(node.componentId)
});
expect(result.status).toBe("succeeded");
expect(result.variables.normalRan).toBe(true);
expect(result.variables.recovered).toBeUndefined();
expect(result.nodes.map((state) => state.nodeId)).toEqual(["start", "normal"]);
});
});

View File

@ -0,0 +1,292 @@
import { readFileSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type {
Catalog,
CatalogComponent,
FlowDefinition,
FlowEdge,
FlowNode,
RunEvent
} from "../../shared/types";
import "../capabilities";
import { register } from "./registry";
import { runFlow } from "./runtime";
const catalog = JSON.parse(
readFileSync(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")
) as Catalog;
const components = new Map(catalog.components.map((component) => [component.id, component]));
const tempDirs: string[] = [];
const baseOptions = {
timeoutMs: 5_000,
retryCount: 0,
retryIntervalMs: 1_000,
skipOnError: false
};
function node(
id: string,
componentId: string,
values: Record<string, unknown> = {},
options: FlowNode["options"] = {},
extra: Partial<FlowNode> = {}
): FlowNode {
return {
id,
componentId,
position: { x: 0, y: 0 },
values,
options: { ...baseOptions, ...options },
...extra
};
}
function edge(source: string, target: string): FlowEdge {
return { id: `${source}->${target}`, source, target };
}
function flow(id: string, nodes: FlowNode[], edges: FlowEdge[] = []): FlowDefinition {
const now = new Date().toISOString();
return {
schemaVersion: 1,
id,
name: id,
variables: [],
nodes,
edges,
meta: { createdAt: now, updatedAt: now, appVersion: "test" }
};
}
function testComponent(id: string): CatalogComponent {
return {
id,
name: id,
kind: "native",
group: "test",
categories: ["test"],
categorySignature: "test",
description: "runtime integration test component",
messageTemplate: "",
baseType: id,
functionName: id,
functionCalls: [],
version: "1",
tags: [],
platformTag: null,
icon: "",
risk: "low",
fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [],
implemented: true
};
}
async function tempDir(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-runtime-integration-"));
tempDirs.push(directory);
return directory;
}
async function execute(
definition: FlowDefinition,
variables: Record<string, unknown> = {},
extraComponents: CatalogComponent[] = []
) {
const directory = await tempDir();
const events: Array<Omit<RunEvent, "runId">> = [];
const logs: Array<{ message: string; level?: "info" | "warn" | "error"; nodeId?: string }> = [];
const componentMap = new Map(components);
for (const component of extraComponents) componentMap.set(component.id, component);
const result = await runFlow(definition, {
runId: `run-${definition.id}`,
evidenceDir: directory,
signal: new AbortController().signal,
variables,
emit: (event) => events.push(event),
log: (message, level, nodeId) => logs.push({ message, level, nodeId }),
componentOf: (current) => componentMap.get(current.componentId)
});
return { result, events, logs, directory };
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("runFlow integration", () => {
it("follows edges in a straight line and passes variables between real capabilities", async () => {
const definition = flow(
"straight-variable-flow",
[
node("debug", "Basic_DebugOutput", { msg: "${upper}" }, {}, { position: { x: 0, y: 10 } }),
node("upper", "StrUtil_UpperCase", { string: "${source}" }, { outputVar: "upper" }),
node("trigger", "Trigger.Manual", {}, {}, { position: { x: 0, y: 300 } }),
node("set", "Basic_SetVariable", { value: "hello rpa" }, { outputVar: "source" })
],
[edge("trigger", "set"), edge("set", "upper"), edge("upper", "debug")]
);
const { result, events, logs } = await execute(definition);
expect(result.status).toBe("succeeded");
expect(result.variables).toMatchObject({ source: "hello rpa", upper: "HELLO RPA" });
expect(result.nodes.map((state) => state.nodeId)).toEqual(["trigger", "set", "upper", "debug"]);
expect(events.filter((event) => event.type === "node.started").map((event) => event.nodeId)).toEqual([
"trigger",
"set",
"upper",
"debug"
]);
expect(logs.some((entry) => entry.nodeId === "debug" && entry.message === "HELLO RPA")).toBe(true);
});
it.each([
{ approved: true, expected: "true", executed: "true-node", absent: "false-node" },
{ approved: false, expected: "false", executed: "false-node", absent: "true-node" }
])("executes only the IF $expected branch", async ({ approved, expected, executed, absent }) => {
const branch = node("branch", "logic_control_if_else", { condition: "${approved}" });
const definition = flow("if-branch-flow", [
branch,
node("true-node", "Basic_SetVariable", { value: "true" }, { outputVar: "branchResult" }, {
parentId: branch.id,
slot: "statement_if"
}),
node("false-node", "Basic_SetVariable", { value: "false" }, { outputVar: "branchResult" }, {
parentId: branch.id,
slot: "statement_else"
})
]);
const { result } = await execute(definition, { approved });
expect(result.status).toBe("succeeded");
expect(result.variables.branchResult).toBe(expected);
expect(result.nodes.find((state) => state.nodeId === executed)?.status).toBe("succeeded");
expect(result.nodes.some((state) => state.nodeId === absent)).toBe(false);
expect(result.nodes.find((state) => state.nodeId === "branch")?.output).toBe(approved);
});
it("runs a count loop inclusively and exposes the loop index to child nodes", async () => {
const loop = node("loop", "logic_control_count_for", {
count_index: "iteration",
count_start_value: "1",
count_end_value: "3",
count_step: "1"
});
const definition = flow("count-loop-flow", [
loop,
node("accumulate", "Basic_SetVariable", { value: "${total + iteration}" }, { outputVar: "total" }, {
parentId: loop.id,
slot: "count_for_fn"
})
]);
const { result, events } = await execute(definition, { total: 0 });
expect(result.status).toBe("succeeded");
expect(result.variables).toMatchObject({ iteration: 3, total: 6 });
expect(result.nodes.find((state) => state.nodeId === "loop")?.output).toBe(3);
expect(events.filter((event) => event.type === "node.started" && event.nodeId === "accumulate")).toHaveLength(3);
});
it("retries a transient failure and records the successful attempt", async () => {
const component = testComponent("Test.TransientFailure");
let calls = 0;
register(component.id, () => {
calls += 1;
if (calls < 3) throw new Error(`temporary failure ${calls}`);
return "recovered";
});
const definition = flow("retry-flow", [
node("retry", component.id, {}, { outputVar: "retryResult", retryCount: 2, retryIntervalMs: 1_000 })
]);
const { result, logs } = await execute(definition, {}, [component]);
expect(result.status).toBe("succeeded");
expect(calls).toBe(3);
expect(result.variables.retryResult).toBe("recovered");
expect(result.nodes[0]).toMatchObject({ status: "succeeded", attempt: 3, output: "recovered" });
expect(logs.filter((entry) => entry.level === "warn" && entry.nodeId === "retry")).toHaveLength(2);
}, 5_000);
it("continues after a skipped failure and executes the following node", async () => {
const component = testComponent("Test.AlwaysFails");
register(component.id, () => {
throw new Error("expected failure");
});
const definition = flow(
"skip-on-error-flow",
[
node("failure", component.id, {}, { skipOnError: true }),
node("after", "Basic_SetVariable", { value: "continued" }, { outputVar: "afterFailure" })
],
[edge("failure", "after")]
);
const { result } = await execute(definition, {}, [component]);
expect(result.status).toBe("succeeded");
expect(result.nodes.find((state) => state.nodeId === "failure")).toMatchObject({
status: "skipped",
error: { code: "unknown", reason: "expected failure" }
});
expect(result.nodes.find((state) => state.nodeId === "after")?.status).toBe("succeeded");
expect(result.variables.afterFailure).toBe("continued");
});
it("runs an offline file pipeline with interpolation and persisted output", async () => {
const directory = await tempDir();
const sourcePath = path.join(directory, "source.txt");
const targetPath = path.join(directory, "result.txt");
const definition = flow(
"file-pipeline-flow",
[
node("write-source", "File_Write", {
path: "${sourcePath}",
content: "${inputText}",
encode: "utf-8",
append: "False"
}),
node("read-source", "File_Read", { path: "${sourcePath}", encode: "utf-8" }, { outputVar: "fileText" }),
node("transform", "StrUtil_UpperCase", { string: "${fileText}" }, { outputVar: "transformed" }),
node("write-target", "File_Write", {
path: "${targetPath}",
content: "${transformed}",
encode: "utf-8",
append: "False"
}),
node("read-target", "File_Read", { path: "${targetPath}", encode: "utf-8" }, { outputVar: "finalText" })
],
[
edge("write-source", "read-source"),
edge("read-source", "transform"),
edge("transform", "write-target"),
edge("write-target", "read-target")
]
);
const { result } = await execute(definition, {
sourcePath,
targetPath,
inputText: "offline rpa pipeline"
});
expect(result.status).toBe("succeeded");
expect(result.variables).toMatchObject({
fileText: "offline rpa pipeline",
transformed: "OFFLINE RPA PIPELINE",
finalText: "OFFLINE RPA PIPELINE"
});
await expect(readFile(targetPath, "utf8")).resolves.toBe("OFFLINE RPA PIPELINE");
expect(result.nodes.every((state) => state.status === "succeeded")).toBe(true);
});
});

View File

@ -137,12 +137,8 @@ export async function runFlow(
options.emit({ type: "run.started", startedAt });
const order = topLevelOrder(flow);
try {
for (const node of order) {
if (options.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
await executeNode(node, ctx, states);
}
await executeTopLevelGraph(flow, ctx, states);
} catch (error) {
if (error instanceof StopSignal) {
options.log("流程被「退出流程」节点终止", "warn");
@ -179,33 +175,47 @@ export async function runFlow(
};
}
function topLevelOrder(flow: FlowDefinition): FlowNode[] {
async function executeTopLevelGraph(flow: FlowDefinition, ctx: RunContext, states: Map<string, NodeState>): Promise<void> {
const top = flow.nodes.filter((node) => !node.parentId);
if (top.length <= 1) return top;
const topIds = new Set(top.map((node) => node.id));
const incoming = new Map<string, number>();
for (const edge of flow.edges) {
if (topIds.has(edge.source) && topIds.has(edge.target)) {
incoming.set(edge.target, (incoming.get(edge.target) || 0) + 1);
if (!top.length) return;
if (!flow.edges.length) {
for (const node of [...top].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x)) {
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
await executeNode(node, ctx, states);
}
return;
}
const topIds = new Set(top.map((node) => node.id));
const topById = new Map(top.map((node) => [node.id, node]));
const edges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));
const incoming = new Set(edges.map((edge) => edge.target));
const start =
top.find((node) => !incoming.get(node.id) && node.componentId.startsWith("Trigger.")) ||
top.find((node) => !incoming.get(node.id)) ||
top.find((node) => !incoming.has(node.id) && node.componentId.startsWith("Trigger.")) ||
top.find((node) => !incoming.has(node.id)) ||
top[0];
const ordered: FlowNode[] = [];
const visited = new Set<string>();
let current: FlowNode | undefined = start;
while (current && !visited.has(current.id)) {
ordered.push(current);
visited.add(current.id);
const edge = flow.edges.find((item) => item.source === current!.id && topIds.has(item.target));
current = edge ? top.find((node) => node.id === edge.target) : undefined;
}
for (const node of [...top].sort((a, b) => a.position.y - b.position.y)) {
if (!visited.has(node.id)) ordered.push(node);
}
return ordered;
const walk = async (node: FlowNode): Promise<void> => {
if (visited.has(node.id)) return;
if (ctx.signal.aborted) throw new RuntimeError("cancelled", "运行已取消");
visited.add(node.id);
try {
await executeNode(node, ctx, states);
} catch (error) {
const errorEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle === "error");
const target = errorEdge ? topById.get(errorEdge.target) : undefined;
if (!target) throw error;
ctx.vars.error = asRuntimeError(error, node, node.componentId);
ctx.log(`节点失败,转入异常分支:${target.label || target.componentId}`, "warn", node.id);
await walk(target);
return;
}
const normalEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle !== "error");
const target = normalEdge ? topById.get(normalEdge.target) : undefined;
if (target) await walk(target);
};
await walk(start);
}
async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string, NodeState>): Promise<unknown> {

View File

@ -8,6 +8,7 @@ export interface BrowserLike {
exists(selector: unknown, timeoutMs?: number): Promise<boolean>;
select(selector: unknown, value: string): Promise<unknown>;
runJs(script: string): Promise<unknown>;
download(selector: unknown, folder: string, filename?: string, timeoutMs?: number): Promise<string>;
screenshot(path: string): Promise<string>;
currentUrl(): Promise<string>;
close(): Promise<void>;

View File

@ -24,6 +24,8 @@ export function createFlow(name = "未命名流程"): FlowDefinition {
id,
name,
description: "",
enabled: true,
trigger: { type: "manual", label: "手动运行" },
variables: [],
nodes: [
{
@ -51,7 +53,10 @@ export function listFlows(): FlowSummary[] {
id: flow.id,
name: flow.name || "未命名流程",
updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(),
nodeCount: flow.nodes.length
nodeCount: flow.nodes.length,
enabled: flow.enabled !== false,
triggerLabel: flow.trigger?.label || "手动运行",
lastRunStatus: "never"
});
} catch {
// 忽略损坏文件,避免一个坏流程阻塞列表

View File

@ -1,14 +1,15 @@
import { useCallback, useEffect, useState } from "react";
import { Bot, CircleAlert, Loader2, Sparkles } from "lucide-react";
import type { Catalog } from "@shared/types";
import { Bot, CircleAlert, Loader2, ListChecks, Sparkles } from "lucide-react";
import type { Catalog, FlowDefinition, FlowSummary } from "@shared/types";
import { useEditorStore } from "./store/editorStore";
import { useRunStore } from "./store/runStore";
import { EditorPage } from "./pages/EditorPage";
import { RunsPage } from "./pages/RunsPage";
import { SettingsPage } from "./pages/SettingsPage";
import { TasksPage } from "./pages/TasksPage";
import { railIcons } from "./components/icons";
type Page = "editor" | "runs" | "settings";
type Page = "tasks" | "editor" | "runs" | "settings";
interface ToastState {
id: number;
@ -17,14 +18,37 @@ interface ToastState {
}
const PAGE_LABEL: Record<Page, string> = {
tasks: "编排",
editor: "流程编排",
runs: "运行中心",
settings: "设置"
};
function normalizeLegacyLinearLayout(flow: FlowDefinition): FlowDefinition {
const topLevel = flow.nodes.filter((node) => !node.parentId);
if (topLevel.length < 2) return flow;
const hasBranch = flow.edges.some(
(edge) => flow.edges.filter((candidate) => candidate.source === edge.source).length > 1
);
if (hasBranch) return flow;
const xValues = topLevel.map((node) => node.position.x);
if (Math.max(...xValues) - Math.min(...xValues) < 120) return flow;
const ordered = [...topLevel].sort((a, b) => a.position.y - b.position.y || a.position.x - b.position.x);
const next = structuredClone(flow);
const nextNodes = new Map(next.nodes.map((node) => [node.id, node]));
ordered.forEach((node, index) => {
const target = nextNodes.get(node.id);
if (target) target.position = { x: 220, y: 70 + index * 142 };
});
return next;
}
export function App() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [page, setPage] = useState<Page>("editor");
const [page, setPage] = useState<Page>("tasks");
const [flows, setFlows] = useState<FlowSummary[]>([]);
const [activeFlowId, setActiveFlowId] = useState<string | null>(null);
const [toast, setToast] = useState<ToastState | null>(null);
const [error, setError] = useState<string | null>(null);
const setCatalogStore = useEditorStore((state) => state.setCatalog);
@ -54,9 +78,12 @@ export function App() {
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);
let availableFlows = await window.rpa.flows.list();
if (!availableFlows.length) {
await window.rpa.flows.create("网页订单录入");
availableFlows = await window.rpa.flows.list();
}
if (!cancelled) setFlows(availableFlows);
} catch (bootError) {
if (!cancelled) setError(bootError instanceof Error ? bootError.message : String(bootError));
}
@ -88,6 +115,69 @@ export function App() {
const executable = catalog.components.filter((item) => item.implemented).length;
const refreshFlows = async () => {
try {
setFlows(await window.rpa.flows.list());
} catch (refreshError) {
showToast(refreshError instanceof Error ? refreshError.message : String(refreshError), "error");
}
};
const openEditor = async (id: string) => {
try {
const loaded = await window.rpa.flows.get(id);
const flow = normalizeLegacyLinearLayout(loaded);
if (flow !== loaded) await window.rpa.flows.save(flow);
loadFlow(flow);
setActiveFlowId(id);
setPage("editor");
} catch (openError) {
showToast(openError instanceof Error ? openError.message : String(openError), "error");
}
};
const createTask = async () => {
try {
const flow = await window.rpa.flows.create("新建任务");
setFlows(await window.rpa.flows.list());
loadFlow(flow);
setActiveFlowId(flow.id);
setPage("editor");
} catch (createError) {
showToast(createError instanceof Error ? createError.message : String(createError), "error");
}
};
const deleteTask = async (id: string) => {
if (flows.length <= 1) {
showToast("至少保留一个任务", "error");
return;
}
try {
await window.rpa.flows.delete(id);
await refreshFlows();
if (activeFlowId === id) {
setActiveFlowId(null);
setPage("tasks");
}
showToast("任务已删除");
} catch (deleteError) {
showToast(deleteError instanceof Error ? deleteError.message : String(deleteError), "error");
}
};
const runTask = async (id: string) => {
try {
const flow = await window.rpa.flows.get(id);
loadFlow(flow);
setActiveFlowId(id);
setPage("runs");
await useRunStore.getState().start(flow);
} catch (runError) {
showToast(runError instanceof Error ? runError.message : String(runError), "error");
}
};
return (
<div className="app">
<header className="titlebar">
@ -99,7 +189,7 @@ export function App() {
</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 === "tasks" ? <ListChecks className="icon" /> : page === "editor" ? <Sparkles className="icon" /> : page === "runs" ? <railIcons.runs className="icon" /> : <railIcons.settings className="icon" />}
{PAGE_LABEL[page]}
</span>
<div className="titlebar-right">
@ -116,19 +206,11 @@ export function App() {
<nav className="rail" aria-label="主导航">
<button
type="button"
className={`rail-item${page === "editor" ? " active" : ""}`}
onClick={() => setPage("editor")}
className={`rail-item${page !== "settings" ? " active" : ""}`}
onClick={() => setPage("tasks")}
>
<railIcons.editor className="icon" />
流程
</button>
<button
type="button"
className={`rail-item${page === "runs" ? " active" : ""}`}
onClick={() => setPage("runs")}
>
<railIcons.runs className="icon" />
运行
<ListChecks className="icon" />
编排
</button>
<button
type="button"
@ -147,8 +229,18 @@ export function App() {
</nav>
<main className="main">
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} />}
{page === "runs" && <RunsPage onBack={() => setPage("editor")} />}
{page === "tasks" && (
<TasksPage
flows={flows}
onCreate={() => void createTask()}
onOpenEditor={(id) => void openEditor(id)}
onRun={(id) => void runTask(id)}
onDelete={(id) => void deleteTask(id)}
onRefresh={() => void refreshFlows()}
/>
)}
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} onBack={() => { setPage("tasks"); void refreshFlows(); }} />}
{page === "runs" && <RunsPage onBack={() => setPage("tasks")} />}
{page === "settings" && <SettingsPage catalog={catalog} />}
</main>

View File

@ -75,7 +75,7 @@ export function ContainerNode({ data, selected }: NodeProps) {
.join(" ")}
onClick={() => selectNode(node.id)}
>
<Handle type="target" position={Position.Left} />
<Handle type="target" position={Position.Top} />
<div className="container-head">
<span className="container-icon">
<Icon className="icon" />
@ -183,7 +183,7 @@ export function ContainerNode({ data, selected }: NodeProps) {
})}
</div>
<Handle type="source" position={Position.Right} />
<Handle type="source" position={Position.Bottom} />
</div>
);
}

View File

@ -131,7 +131,8 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
label: node.label,
subtitle: component ? subtitleFor(component, node.values) : undefined,
outputVar: node.options?.outputVar,
status: runNodes[node.id]?.status
status: runNodes[node.id]?.status,
errorRouteEnabled: Boolean(node.options?.errorRouteEnabled)
} as RpaNodeData as unknown as Record<string, unknown>
} as Node;
});
@ -142,15 +143,27 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
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" }
}));
.map((edge) => {
const errorEdge = edge.sourceHandle === "error";
const color = errorEdge ? "#b45309" : "#b8c5d6";
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle || undefined,
targetHandle: edge.targetHandle || undefined,
type: "smoothstep",
selectable: true,
deletable: true,
focusable: true,
interactionWidth: 30,
label: errorEdge ? "异常" : edge.label,
className: errorEdge ? "error-edge" : undefined,
style: { stroke: color },
labelStyle: errorEdge ? { fill: color, fontSize: 10, fontWeight: 700 } : undefined,
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color }
};
});
}, [flow]);
const handleNodesChange = useCallback(
@ -220,8 +233,11 @@ function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (compone
onConnect={handleConnect}
onPaneClick={() => selectNode(null)}
onNodeClick={(_event, node) => selectNode(node.id)}
onEdgeDoubleClick={(_event, edge) => removeEdge(edge.id)}
defaultEdgeOptions={{ type: "smoothstep" }}
deleteKeyCode={["Delete", "Backspace"]}
edgesFocusable
elementsSelectable
minZoom={0.3}
maxZoom={2}
fitView

View File

@ -8,6 +8,7 @@ export interface RpaNodeData extends Record<string, unknown> {
subtitle?: string;
outputVar?: string;
status?: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
errorRouteEnabled?: boolean;
}
export function RpaNode({ data, selected }: NodeProps) {
@ -28,7 +29,7 @@ export function RpaNode({ data, selected }: NodeProps) {
.filter(Boolean)
.join(" ")}
>
<Handle type="target" position={Position.Left} className="rpa-handle" />
<Handle type="target" position={Position.Top} className="rpa-handle" />
<span className="rpa-node-icon">
<Icon className="icon" />
</span>
@ -41,7 +42,10 @@ export function RpaNode({ data, selected }: NodeProps) {
<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" />
<Handle type="source" position={Position.Bottom} className="rpa-handle" />
{nodeData.errorRouteEnabled && (
<Handle type="source" id="error" position={Position.Right} className="rpa-handle rpa-error-handle" />
)}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { Check, CirclePlay, Loader2, Redo2, Save, Square, Undo2 } from "lucide-react";
import { ArrowLeft, Check, CirclePlay, Loader2, Redo2, Save, Square, Undo2 } from "lucide-react";
import type { Catalog } from "@shared/types";
import { useEditorStore } from "../store/editorStore";
import { useRunStore } from "../store/runStore";
@ -12,9 +12,10 @@ import { containerSlots } from "../canvas/ContainerNode";
interface EditorPageProps {
catalog: Catalog;
onToast: (message: string, kind?: "ok" | "error") => void;
onBack: () => void;
}
export function EditorPage({ catalog, onToast }: EditorPageProps) {
export function EditorPage({ catalog, onToast, onBack }: EditorPageProps) {
const flow = useEditorStore((state) => state.flow);
const dirty = useEditorStore((state) => state.dirty);
const addNode = useEditorStore((state) => state.addNode);
@ -116,6 +117,13 @@ export function EditorPage({ catalog, onToast }: EditorPageProps) {
<section className="panel canvas-panel" aria-label="流程画布">
<div className="canvas-toolbar">
<div className="flow-meta">
<div className="editor-breadcrumb">
<button type="button" className="back-link" onClick={onBack}>
<ArrowLeft className="icon" /> 任务中心
</button>
<span>/</span>
<span>编辑流程</span>
</div>
<input
className="flow-name"
value={flow.name}

View File

@ -0,0 +1,173 @@
import { useEffect, useMemo, useState } from "react";
import {
CalendarClock,
CheckCircle2,
CircleAlert,
CirclePlay,
Clock3,
ListChecks,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
Zap
} from "lucide-react";
import type { FlowSummary } from "@shared/types";
interface TasksPageProps {
flows: FlowSummary[];
onCreate: () => void;
onOpenEditor: (id: string) => void;
onRun: (id: string) => void;
onDelete: (id: string) => void;
onRefresh: () => void;
}
function formatTime(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "未记录";
return date.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
}
export function TasksPage({ flows, onCreate, onOpenEditor, onRun, onDelete, onRefresh }: TasksPageProps) {
const [query, setQuery] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(flows[0]?.id || null);
useEffect(() => {
if (!flows.length) {
setSelectedId(null);
return;
}
if (!selectedId || !flows.some((flow) => flow.id === selectedId)) setSelectedId(flows[0].id);
}, [flows, selectedId]);
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return flows;
return flows.filter((flow) => flow.name.toLowerCase().includes(keyword));
}, [flows, query]);
const selected = flows.find((flow) => flow.id === selectedId) || filtered[0] || null;
const enabledCount = flows.filter((flow) => flow.enabled !== false).length;
const failedCount = flows.filter((flow) => flow.lastRunStatus === "failed").length;
return (
<div className="tasks-page">
<header className="page-heading">
<div>
<div className="eyebrow">自动化工作区 / 编排</div>
<h1>编排</h1>
<p>选择一个任务开始运行或编辑流程;组件库只在进入编辑时参与搭建。</p>
</div>
<div className="page-heading-actions">
<button type="button" className="icon-btn" title="刷新任务列表" onClick={onRefresh}>
<RefreshCw className="icon" />
</button>
<button type="button" className="btn btn-primary" onClick={onCreate}>
<Plus className="icon" /> 新建任务
</button>
</div>
</header>
<div className="task-stats" aria-label="任务统计">
<div className="task-stat"><span>全部任务</span><strong>{flows.length}</strong><ListChecks className="icon" /></div>
<div className="task-stat"><span>已启用</span><strong>{enabledCount}</strong><Zap className="icon" /></div>
<div className="task-stat"><span>最近失败</span><strong>{failedCount}</strong><CircleAlert className="icon" /></div>
<div className="task-stat"><span>本地执行</span><strong>离线</strong><CheckCircle2 className="icon" /></div>
</div>
<div className="tasks-content">
<section className="panel task-list-panel" aria-label="任务列表">
<div className="panel-head task-list-head">
<div>
<strong>任务列表</strong>
<span className="panel-subtitle">按最近修改时间排序</span>
</div>
<label className="task-search">
<Search className="icon" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索任务" aria-label="搜索任务" />
</label>
</div>
<div className="task-table-head" aria-hidden="true">
<span>任务</span>
<span>状态</span>
<span>触发方式</span>
<span>最近修改</span>
<span />
</div>
<div className="task-list">
{filtered.map((flow) => (
<div
className={`task-row${flow.id === selected?.id ? " selected" : ""}`}
key={flow.id}
onClick={() => setSelectedId(flow.id)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") setSelectedId(flow.id);
}}
>
<div className="task-name-cell">
<span className="task-icon"><ListChecks className="icon" /></span>
<span className="task-copy"><strong>{flow.name}</strong><small>{flow.nodeCount} 个节点</small></span>
</div>
<span className={`task-status ${flow.enabled === false ? "paused" : "enabled"}`}>
<span className="status-dot" />{flow.enabled === false ? "未启用" : "已启用"}
</span>
<span className="task-trigger"><CalendarClock className="icon" />{flow.triggerLabel || "手动运行"}</span>
<span className="task-updated"><Clock3 className="icon" />{formatTime(flow.updatedAt)}</span>
<span className="task-row-actions">
<button type="button" className="icon-btn" title="运行任务" onClick={(event) => { event.stopPropagation(); onRun(flow.id); }}>
<CirclePlay className="icon" />
</button>
<button type="button" className="icon-btn" title="编辑流程" onClick={(event) => { event.stopPropagation(); onOpenEditor(flow.id); }}>
<Pencil className="icon" />
</button>
<button type="button" className="icon-btn danger-icon" title="删除任务" onClick={(event) => { event.stopPropagation(); onDelete(flow.id); }}>
<Trash2 className="icon" />
</button>
</span>
</div>
))}
{!filtered.length && (
<div className="task-empty"><ListChecks className="icon" /><strong>没有匹配的任务</strong><span>调整搜索条件,或新建一个自动化任务。</span></div>
)}
</div>
</section>
<aside className="panel task-overview" aria-label="任务概览">
{selected ? (
<>
<div className="task-overview-head">
<span className="task-icon large"><ListChecks className="icon" /></span>
<div><span className="eyebrow">任务概览</span><h2>{selected.name}</h2></div>
</div>
<div className="overview-status"><span className="status-dot" />{selected.enabled === false ? "未启用" : "已启用"}<span className="overview-sep" />本地执行</div>
<div className="overview-actions">
<button type="button" className="btn btn-primary" onClick={() => onRun(selected.id)}><CirclePlay className="icon" />运行任务</button>
<button type="button" className="btn" onClick={() => onOpenEditor(selected.id)}><Pencil className="icon" />编辑流程</button>
</div>
<dl className="overview-meta">
<div><dt>触发方式</dt><dd><CalendarClock className="icon" />{selected.triggerLabel || "手动运行"}</dd></div>
<div><dt>流程节点</dt><dd>{selected.nodeCount} 个</dd></div>
<div><dt>最近运行</dt><dd>{selected.lastRunAt ? formatTime(selected.lastRunAt) : "尚未运行"}</dd></div>
<div><dt>最近修改</dt><dd>{formatTime(selected.updatedAt)}</dd></div>
</dl>
<div className="overview-note"><Zap className="icon" /><span>任务打开后先查看概览;只有点击“编辑流程”才进入画布。</span></div>
</>
) : (
<div className="task-empty overview-empty"><ListChecks className="icon" /><strong>还没有任务</strong><span>新建一个任务开始搭建自动化流程。</span><button type="button" className="btn btn-primary" onClick={onCreate}><Plus className="icon" />新建任务</button></div>
)}
</aside>
</div>
</div>
);
}

View File

@ -1,25 +1,26 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronDown, Search, Sparkles } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, 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;
}
interface ComponentGroup {
id: string;
label: string;
count: number;
components: CatalogComponent[];
}
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 [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const executableCount = useMemo(
() => catalog.components.filter((item) => item.implemented).length,
@ -30,7 +31,6 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
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;
@ -45,35 +45,55 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
.toLowerCase();
return haystack.includes(keyword);
});
}, [catalog.components, group, query, status]);
}, [catalog.components, query, status]);
const groups = useMemo<ComponentGroup[]>(() => {
const byGroup = new Map<string, CatalogComponent[]>();
for (const component of filtered) {
const current = byGroup.get(component.group) || [];
current.push(component);
byGroup.set(component.group, current);
}
return catalog.groups
.map((group) => ({
id: group.id,
label: group.label,
count: group.count,
components: byGroup.get(group.id) || []
}))
.filter((group) => group.components.length > 0);
}, [catalog.groups, filtered]);
useEffect(() => {
if (!listRef.current) return;
const observer = new ResizeObserver((entries) => {
const next = entries[0]?.contentRect.height;
if (next) setViewport(next);
if (query.trim()) return;
setExpanded((current) => current.size ? current : new Set(catalog.groups.slice(0, 2).map((group) => group.id)));
}, [catalog.groups, query]);
useEffect(() => {
if (!query.trim()) return;
setExpanded(new Set(groups.map((group) => group.id)));
}, [groups, query]);
const allExpanded = groups.length > 0 && groups.every((group) => expanded.has(group.id));
const toggleGroup = (id: string) => {
setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return 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);
const toggleAll = () => {
setExpanded(allExpanded ? new Set() : new Set(groups.map((group) => group.id)));
};
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>
<span className="palette-count">{filtered.length} / {catalog.counts.total}</span>
</div>
<label className="search-box">
@ -87,95 +107,47 @@ export function Palette({ catalog, onAdd }: PaletteProps) {
</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>
<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>
<button type="button" className="palette-expand-all" onClick={toggleAll}>
{allExpanded ? <ChevronDown className="icon" /> : <ChevronRight className="icon" />}
{allExpanded ? "收起全部" : "展开全部分类"}
</button>
</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="palette-list palette-tree">
{groups.map((group) => {
const Icon = groupIcon(group.id);
const isOpen = expanded.has(group.id);
return (
<section className="palette-group" key={group.id}>
<button type="button" className={`palette-group-row${isOpen ? " open" : ""}`} onClick={() => toggleGroup(group.id)}>
{isOpen ? <ChevronDown className="icon" /> : <ChevronRight className="icon" />}
<span className="palette-group-icon"><Icon className="icon" /></span>
<span className="palette-group-copy"><strong>{group.label}</strong><small>{group.components.length} / {group.count}</small></span>
</button>
{isOpen && <div className="palette-group-children">{group.components.map((component) => <PaletteRow key={component.id} component={component} onAdd={onAdd} />)}</div>}
</section>
);
})}
{!groups.length && (
<div className="empty" style={{ minHeight: 200 }}>
<Sparkles className="icon" />
<strong>没有匹配的组件</strong>
<span>换个关键词,或切换状态/分组筛选。</span>
<span>换个关键词,或切换状态筛选。</span>
</div>
)}
</div>
<div className="palette-more">
<ChevronDown className="icon" style={{ width: 12, height: 12 }} />
拖拽到画布,或单击追加;灰点为待接入。
</div>
<div className="palette-more"><ChevronDown className="icon" style={{ width: 12, height: 12 }} />拖拽叶子组件到画布;灰点为待接入。</div>
</aside>
);
}
function PaletteRow({
component,
top,
onAdd
}: {
component: CatalogComponent;
top: number;
onAdd: (id: string) => void;
}) {
function PaletteRow({ component, onAdd }: { component: CatalogComponent; onAdd: (id: string) => void }) {
const Icon = groupIcon(component.group);
return (
<button
@ -183,7 +155,6 @@ function PaletteRow({
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";
@ -191,13 +162,8 @@ function PaletteRow({
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="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

@ -375,6 +375,15 @@ function PropertyTab({
/>
记录异常堆栈
</label>
<label className="checkbox-row exception-route-toggle">
<input
type="checkbox"
checked={Boolean(node.options.errorRouteEnabled)}
onChange={(event) => onChangeOption(node.id, "errorRouteEnabled", event.target.checked)}
/>
显示异常出口
</label>
<span className="field-hint">开启后,节点下方显示可选的异常出口;红色仅用于真实运行失败。</span>
</section>
</>
);

View File

@ -33,7 +33,8 @@ function defaultOptions(): FlowNodeOptions {
retryCount: 0,
retryIntervalMs: 1000,
skipOnError: false,
logException: true
logException: true,
errorRouteEnabled: false
};
}

View File

@ -910,11 +910,12 @@ textarea:focus-visible {
}
.react-flow__handle {
width: 9px;
height: 9px;
width: 16px;
height: 16px;
border: 2px solid #fff;
background: #9aa7b8;
box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.1);
box-shadow: 0 0 0 1px rgba(23, 32, 51, 0.12), 0 2px 5px rgba(23, 32, 51, 0.12);
z-index: 4;
}
.react-flow__handle:hover {
@ -923,11 +924,23 @@ textarea:focus-visible {
.react-flow__edge-path {
stroke: #b8c5d6;
stroke-width: 1.8;
stroke-width: 2.2;
pointer-events: stroke;
}
.react-flow__edge.selected .react-flow__edge-path {
stroke: var(--blue);
stroke-width: 3;
}
.react-flow__edge:hover .react-flow__edge-path {
stroke: var(--blue-dark);
stroke-width: 3;
}
.react-flow__edge.error-edge:hover .react-flow__edge-path,
.react-flow__edge.error-edge.selected .react-flow__edge-path {
stroke: var(--warn);
}
.react-flow__controls {
@ -2094,3 +2107,632 @@ textarea:focus-visible {
font-size: 9.5px;
font-weight: 650;
}
/* ---------- 任务中心 ---------- */
.tasks-page {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
gap: 14px;
overflow: hidden;
}
.page-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 4px 4px 0;
flex: none;
}
.page-heading h1 {
margin-top: 4px;
color: var(--ink);
font-size: 24px;
line-height: 32px;
letter-spacing: -0.01em;
}
.page-heading p {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
line-height: 18px;
}
.eyebrow {
color: var(--muted);
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.page-heading-actions,
.overview-actions {
display: flex;
align-items: center;
gap: 8px;
}
.task-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
flex: none;
}
.task-stat {
min-height: 70px;
padding: 13px 14px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: 18px 24px;
align-items: center;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(255, 255, 255, 0.82);
box-shadow: var(--shadow-panel);
}
.task-stat span {
color: var(--muted);
font-size: 11px;
}
.task-stat strong {
color: var(--ink);
font-size: 20px;
line-height: 24px;
font-variant-numeric: tabular-nums;
}
.task-stat .icon {
grid-column: 2;
grid-row: 1 / -1;
width: 20px;
height: 20px;
color: var(--blue);
}
.tasks-content {
min-height: 0;
flex: 1;
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(300px, 0.75fr);
gap: 12px;
}
.task-list-panel,
.task-overview {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.task-list-head {
min-height: 62px;
align-items: center;
}
.task-list-head > div:first-child {
display: flex;
flex-direction: column;
gap: 3px;
}
.panel-subtitle {
color: var(--muted);
font-size: 10.5px;
font-weight: 500;
}
.task-search {
width: 210px;
height: 32px;
padding: 0 9px;
display: flex;
align-items: center;
gap: 7px;
border: 1px solid var(--line-strong);
border-radius: 9px;
background: #fff;
}
.task-search:focus-within {
border-color: rgba(34, 100, 229, 0.42);
box-shadow: var(--focus);
}
.task-search .icon {
width: 14px;
height: 14px;
color: var(--muted);
}
.task-search input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
font-size: 11.5px;
}
.task-table-head,
.task-row {
display: grid;
grid-template-columns: minmax(190px, 1.35fr) 90px minmax(100px, 0.8fr) minmax(100px, 0.85fr) 106px;
gap: 10px;
align-items: center;
}
.task-table-head {
min-height: 34px;
padding: 0 14px;
color: var(--faint);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: #fbfcfe;
font-size: 10px;
font-weight: 700;
}
.task-list {
min-height: 0;
flex: 1;
overflow: auto;
}
.task-row {
min-height: 68px;
padding: 8px 14px;
border-bottom: 1px solid var(--line);
cursor: pointer;
transition: background 0.14s ease, box-shadow 0.14s ease;
}
.task-row:hover {
background: #fbfdff;
}
.task-row.selected {
background: var(--blue-mist);
box-shadow: inset 3px 0 0 var(--blue);
}
.task-name-cell,
.task-trigger,
.task-updated,
.task-status,
.overview-status,
.overview-meta dd {
display: flex;
align-items: center;
min-width: 0;
gap: 7px;
}
.task-icon {
width: 32px;
height: 32px;
flex: none;
display: grid;
place-items: center;
border-radius: 9px;
background: var(--blue-soft);
color: var(--blue-dark);
}
.task-icon.large {
width: 42px;
height: 42px;
border-radius: 11px;
}
.task-icon .icon {
width: 16px;
height: 16px;
}
.task-icon.large .icon {
width: 20px;
height: 20px;
}
.task-copy {
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.task-copy strong {
overflow: hidden;
color: var(--ink);
font-size: 12px;
line-height: 17px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-copy small,
.task-trigger,
.task-updated {
overflow: hidden;
color: var(--muted);
font-size: 10.5px;
line-height: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-trigger .icon,
.task-updated .icon {
width: 13px;
height: 13px;
flex: none;
}
.task-status {
font-size: 10.5px;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 50%;
background: var(--success);
box-shadow: 0 0 0 3px rgba(7, 148, 85, 0.08);
}
.task-status.paused .status-dot {
background: var(--faint);
box-shadow: 0 0 0 3px rgba(161, 172, 186, 0.12);
}
.task-status.paused {
color: var(--muted);
}
.task-status.enabled {
color: var(--success-deep);
}
.task-row-actions {
display: flex;
justify-content: flex-end;
gap: 2px;
opacity: 0.35;
transition: opacity 0.14s ease;
}
.task-row:hover .task-row-actions,
.task-row.selected .task-row-actions {
opacity: 1;
}
.task-row-actions .icon-btn {
width: 28px;
height: 28px;
border-radius: 7px;
}
.danger-icon:hover {
color: var(--danger);
background: var(--danger-bg);
}
.task-empty {
min-height: 210px;
padding: 24px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--muted);
text-align: center;
}
.task-empty .icon {
width: 28px;
height: 28px;
color: var(--faint);
}
.task-empty strong {
color: var(--ink-2);
font-size: 13px;
}
.task-empty span {
max-width: 260px;
font-size: 11px;
line-height: 17px;
}
.overview-empty {
flex: 1;
}
.task-overview {
padding: 20px;
}
.task-overview-head {
display: flex;
align-items: center;
gap: 11px;
}
.task-overview-head h2 {
max-width: 230px;
margin-top: 3px;
overflow: hidden;
color: var(--ink);
font-size: 17px;
line-height: 23px;
text-overflow: ellipsis;
white-space: nowrap;
}
.overview-status {
margin-top: 16px;
color: var(--success-deep);
font-size: 11px;
}
.overview-sep {
width: 1px;
height: 14px;
background: var(--line-strong);
}
.overview-actions {
margin-top: 16px;
}
.overview-actions .btn {
flex: 1;
justify-content: center;
}
.overview-meta {
margin-top: 20px;
display: grid;
gap: 0;
border-top: 1px solid var(--line);
}
.overview-meta > div {
min-height: 48px;
padding: 8px 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid var(--line);
}
.overview-meta dt {
color: var(--muted);
font-size: 11px;
}
.overview-meta dd {
justify-content: flex-end;
color: var(--ink-2);
font-size: 11px;
text-align: right;
}
.overview-meta dd .icon {
width: 13px;
height: 13px;
color: var(--blue);
}
.overview-note {
margin-top: auto;
padding: 10px;
display: flex;
align-items: flex-start;
gap: 8px;
border: 1px solid rgba(34, 100, 229, 0.1);
border-radius: 9px;
background: var(--blue-mist);
color: var(--ink-2);
font-size: 10.5px;
line-height: 16px;
}
.overview-note .icon {
width: 14px;
height: 14px;
flex: none;
color: var(--blue);
}
/* ---------- 组件树 ---------- */
.palette-expand-all {
min-height: 24px;
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--blue-dark);
font-size: 10.5px;
font-weight: 650;
}
.palette-expand-all:hover {
color: var(--blue);
}
.palette-expand-all .icon {
width: 13px;
height: 13px;
}
.palette-tree {
padding: 6px 8px 10px;
}
.palette-group {
border-bottom: 1px solid var(--line);
}
.palette-group-row {
width: 100%;
min-height: 44px;
padding: 5px 4px;
display: grid;
grid-template-columns: 14px 30px minmax(0, 1fr);
gap: 7px;
align-items: center;
text-align: left;
color: var(--ink-2);
border-radius: 8px;
}
.palette-group-row:hover,
.palette-group-row.open {
background: var(--soft);
color: var(--ink);
}
.palette-group-row > .icon {
width: 13px;
height: 13px;
color: var(--faint);
}
.palette-group-icon {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--blue-soft);
color: var(--blue-dark);
}
.palette-group-icon .icon {
width: 15px;
height: 15px;
}
.palette-group-copy {
min-width: 0;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.palette-group-copy strong {
overflow: hidden;
color: inherit;
font-size: 11.5px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.palette-group-copy small {
flex: none;
color: var(--muted);
font-size: 9.5px;
font-variant-numeric: tabular-nums;
}
.palette-group-children {
padding: 0 0 5px 17px;
border-left: 1px solid var(--line-strong);
margin-left: 10px;
}
.palette-group-children .palette-row {
position: relative;
top: auto;
left: auto;
right: auto;
width: 100%;
height: 52px;
min-height: 52px;
margin: 1px 0;
padding: 6px 6px;
grid-template-columns: 28px minmax(0, 1fr) 12px;
gap: 8px;
border-radius: 8px;
}
.palette-group-children .palette-icon {
width: 28px;
height: 28px;
border-radius: 8px;
}
.palette-group-children .palette-icon .icon {
width: 14px;
height: 14px;
}
.palette-group-children .palette-name {
font-size: 10.5px;
}
.palette-group-children .palette-id {
font-size: 8.5px;
}
/* ---------- 编辑器返回路径与上下连线 ---------- */
.editor-breadcrumb {
min-height: 18px;
display: flex;
align-items: center;
gap: 5px;
color: var(--faint);
font-size: 10px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--blue-dark);
font-size: 10px;
font-weight: 650;
}
.back-link:hover {
color: var(--blue);
}
.back-link .icon {
width: 13px;
height: 13px;
}
.rpa-error-handle {
background: var(--warn) !important;
border-color: #fff !important;
}
@media (max-width: 1250px) {
.tasks-content {
grid-template-columns: minmax(0, 1fr) 300px;
}
.task-table-head,
.task-row {
grid-template-columns: minmax(170px, 1.3fr) 82px minmax(90px, 0.8fr) 96px 96px;
}
}

View File

@ -99,6 +99,7 @@ export interface FlowNodeOptions {
retryIntervalMs?: number;
skipOnError?: boolean;
logException?: boolean;
errorRouteEnabled?: boolean;
}
export interface FlowNode {
@ -129,6 +130,11 @@ export interface FlowDefinition {
id: string;
name: string;
description?: string;
enabled?: boolean;
trigger?: {
type: "manual" | "schedule" | "file";
label: string;
};
variables: FlowVariable[];
nodes: FlowNode[];
edges: FlowEdge[];
@ -144,6 +150,10 @@ export interface FlowSummary {
name: string;
updatedAt: string;
nodeCount: number;
enabled?: boolean;
triggerLabel?: string;
lastRunAt?: string;
lastRunStatus?: "succeeded" | "failed" | "cancelled" | "never";
}
export interface AppInfo {