feat: 完善RPA编排与本地能力接入
This commit is contained in:
parent
6a2cc24898
commit
c03380168d
@ -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/邮件属于用户显式配置的外部系统。
|
||||
|
||||
@ -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 确认)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`);
|
||||
|
||||
162
src/main/capabilities/capabilities.test.ts
Normal file
162
src/main/capabilities/capabilities.test.ts
Normal 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);
|
||||
});
|
||||
39
src/main/capabilities/capability-audit.test.ts
Normal file
39
src/main/capabilities/capability-audit.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
163
src/main/capabilities/desktop.ts
Normal file
163
src/main/capabilities/desktop.ts
Normal 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);
|
||||
107
src/main/capabilities/extended.test.ts
Normal file
107
src/main/capabilities/extended.test.ts
Normal 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"
|
||||
});
|
||||
});
|
||||
});
|
||||
284
src/main/capabilities/extended.ts
Normal file
284
src/main/capabilities/extended.ts
Normal 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 };
|
||||
});
|
||||
@ -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";
|
||||
|
||||
31
src/main/capabilities/message.ts
Normal file
31
src/main/capabilities/message.ts
Normal 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 };
|
||||
});
|
||||
222
src/main/capabilities/office.ts
Normal file
222
src/main/capabilities/office.ts
Normal 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;
|
||||
});
|
||||
62
src/main/capabilities/pdf.ts
Normal file
62
src/main/capabilities/pdf.ts
Normal 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");
|
||||
});
|
||||
97
src/main/capabilities/vision.ts
Normal file
97
src/main/capabilities/vision.ts
Normal 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 } };
|
||||
});
|
||||
44
src/main/capabilities/word.ts
Normal file
44
src/main/capabilities/word.ts
Normal 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));
|
||||
@ -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;
|
||||
|
||||
76
src/main/engine/runtime.error-route.test.ts
Normal file
76
src/main/engine/runtime.error-route.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
292
src/main/engine/runtime.integration.test.ts
Normal file
292
src/main/engine/runtime.integration.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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> {
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 {
|
||||
// 忽略损坏文件,避免一个坏流程阻塞列表
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
|
||||
173
src/renderer/src/pages/TasksPage.tsx
Normal file
173
src/renderer/src/pages/TasksPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -33,7 +33,8 @@ function defaultOptions(): FlowNodeOptions {
|
||||
retryCount: 0,
|
||||
retryIntervalMs: 1000,
|
||||
skipOnError: false,
|
||||
logException: true
|
||||
logException: true,
|
||||
errorRouteEnabled: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user