From c03380168de255c4477da794d0ecb009d17c2099 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 27 Oct 2026 11:10:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84RPA=E7=BC=96=E6=8E=92?= =?UTF-8?q?=E4=B8=8E=E6=9C=AC=E5=9C=B0=E8=83=BD=E5=8A=9B=E6=8E=A5=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- docs/02-实施方案.md | 2 + src/main/browser/session.ts | 27 + src/main/capabilities/browser.ts | 15 +- src/main/capabilities/capabilities.test.ts | 162 +++++ .../capabilities/capability-audit.test.ts | 39 ++ src/main/capabilities/desktop.ts | 163 +++++ src/main/capabilities/extended.test.ts | 107 +++ src/main/capabilities/extended.ts | 284 ++++++++ src/main/capabilities/index.ts | 7 + src/main/capabilities/message.ts | 31 + src/main/capabilities/office.ts | 222 ++++++ src/main/capabilities/pdf.ts | 62 ++ src/main/capabilities/vision.ts | 97 +++ src/main/capabilities/word.ts | 44 ++ src/main/catalog.ts | 12 +- src/main/engine/runtime.error-route.test.ts | 76 ++ src/main/engine/runtime.integration.test.ts | 292 ++++++++ src/main/engine/runtime.ts | 62 +- src/main/engine/types.ts | 1 + src/main/flows.ts | 7 +- src/renderer/src/App.tsx | 136 +++- src/renderer/src/canvas/ContainerNode.tsx | 4 +- src/renderer/src/canvas/FlowCanvas.tsx | 36 +- src/renderer/src/canvas/RpaNode.tsx | 8 +- src/renderer/src/pages/EditorPage.tsx | 12 +- src/renderer/src/pages/TasksPage.tsx | 173 +++++ src/renderer/src/panels/Palette.tsx | 188 +++-- src/renderer/src/panels/Properties.tsx | 9 + src/renderer/src/store/editorStore.ts | 3 +- src/renderer/src/styles/app.css | 650 +++++++++++++++++- src/shared/types.ts | 10 + 32 files changed, 2763 insertions(+), 185 deletions(-) create mode 100644 src/main/capabilities/capabilities.test.ts create mode 100644 src/main/capabilities/capability-audit.test.ts create mode 100644 src/main/capabilities/desktop.ts create mode 100644 src/main/capabilities/extended.test.ts create mode 100644 src/main/capabilities/extended.ts create mode 100644 src/main/capabilities/message.ts create mode 100644 src/main/capabilities/office.ts create mode 100644 src/main/capabilities/pdf.ts create mode 100644 src/main/capabilities/vision.ts create mode 100644 src/main/capabilities/word.ts create mode 100644 src/main/engine/runtime.error-route.test.ts create mode 100644 src/main/engine/runtime.integration.test.ts create mode 100644 src/renderer/src/pages/TasksPage.tsx diff --git a/README.md b/README.md index 9c59119..33c789c 100644 --- a/README.md +++ b/README.md @@ -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/邮件属于用户显式配置的外部系统。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index eb7a500..50173b1 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -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 确认) diff --git a/src/main/browser/session.ts b/src/main/browser/session.ts index 78121f3..096e707 100644 --- a/src/main/browser/session.ts +++ b/src/main/browser/session.ts @@ -283,6 +283,33 @@ export class BrowserSession implements BrowserLike { return this.evaluate(script); } + async download(selector: unknown, folder: string, filename = "", timeoutMs = 10000): Promise { + const targetFolder = path.resolve(folder); + await fs.mkdir(targetFolder, { recursive: true }); + return new Promise((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 { const result = await this.cdp<{ data: string }>("Page.captureScreenshot", { format: "png" }); const target = path.resolve(filePath); diff --git a/src/main/capabilities/browser.ts b/src/main/capabilities/browser.ts index c3f50dc..57fd1b3 100644 --- a/src/main/capabilities/browser.ts +++ b/src/main/capabilities/browser.ts @@ -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`); diff --git a/src/main/capabilities/capabilities.test.ts b/src/main/capabilities/capabilities.test.ts new file mode 100644 index 0000000..5445938 --- /dev/null +++ b/src/main/capabilities/capabilities.test.ts @@ -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 { + 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((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((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); +}); diff --git a/src/main/capabilities/capability-audit.test.ts b/src/main/capabilities/capability-audit.test.ts new file mode 100644 index 0000000..53c6a22 --- /dev/null +++ b/src/main/capabilities/capability-audit.test.ts @@ -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([]); + }); +}); diff --git a/src/main/capabilities/desktop.ts b/src/main/capabilities/desktop.ts new file mode 100644 index 0000000..da27c4b --- /dev/null +++ b/src/main/capabilities/desktop.ts @@ -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, 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, 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 { + 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); diff --git a/src/main/capabilities/extended.test.ts b/src/main/capabilities/extended.test.ts new file mode 100644 index 0000000..f07884e --- /dev/null +++ b/src/main/capabilities/extended.test.ts @@ -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 { + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-extended-")); + directories.push(directory); + return directory; +} + +async function run(componentId: string, args: Record): Promise { + 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: "RPA" }); + 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("RPA"); + + 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" + }); + }); +}); diff --git a/src/main/capabilities/extended.ts b/src/main/capabilities/extended.ts new file mode 100644 index 0000000..ce2c2da --- /dev/null +++ b/src/main/capabilities/extended.ts @@ -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, 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, 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 { + 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 { + const results: string[] = []; + async function walk(folder: string): Promise { + 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): 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, 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, nodeId: string, componentId: string): Promise { + 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 }; +}); diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 78b02ed..9106125 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -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"; diff --git a/src/main/capabilities/message.ts b/src/main/capabilities/message.ts new file mode 100644 index 0000000..cb44836 --- /dev/null +++ b/src/main/capabilities/message.ts @@ -0,0 +1,31 @@ +import nodemailer from "nodemailer"; +import { register } from "../engine/registry"; + +function text(args: Record, 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 }; +}); diff --git a/src/main/capabilities/office.ts b/src/main/capabilities/office.ts new file mode 100644 index 0000000..ae70e90 --- /dev/null +++ b/src/main/capabilities/office.ts @@ -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(); + +function value(args: Record, 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, 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, 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).id || "") : String(raw || ""); + const existing = id ? workbooks.get(id) : undefined; + if (existing) return { handle: { id, path: String((raw as Partial).path || "") }, workbook: existing }; + const filePath = required(typeof raw === "object" ? (raw as Partial).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; +}); diff --git a/src/main/capabilities/pdf.ts b/src/main/capabilities/pdf.ts new file mode 100644 index 0000000..08d123f --- /dev/null +++ b/src/main/capabilities/pdf.ts @@ -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, 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"); +}); diff --git a/src/main/capabilities/vision.ts b/src/main/capabilities/vision.ts new file mode 100644 index 0000000..15fb7c5 --- /dev/null +++ b/src/main/capabilities/vision.ts @@ -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, 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 { + 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 { + 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, evidenceDir: string, nodeId: string): Promise { + 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 } }; +}); diff --git a/src/main/capabilities/word.ts b/src/main/capabilities/word.ts new file mode 100644 index 0000000..6da20c6 --- /dev/null +++ b/src/main/capabilities/word.ts @@ -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, 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 { + 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)); diff --git a/src/main/catalog.ts b/src/main/catalog.ts index 1a7579c..690ab88 100644 --- a/src/main/catalog.ts +++ b/src/main/catalog.ts @@ -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; diff --git a/src/main/engine/runtime.error-route.test.ts b/src/main/engine/runtime.error-route.test.ts new file mode 100644 index 0000000..8b6b71a --- /dev/null +++ b/src/main/engine/runtime.error-route.test.ts @@ -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(); +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"]); + }); +}); diff --git a/src/main/engine/runtime.integration.test.ts b/src/main/engine/runtime.integration.test.ts new file mode 100644 index 0000000..3db97aa --- /dev/null +++ b/src/main/engine/runtime.integration.test.ts @@ -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 = {}, + options: FlowNode["options"] = {}, + extra: Partial = {} +): 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 { + const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-runtime-integration-")); + tempDirs.push(directory); + return directory; +} + +async function execute( + definition: FlowDefinition, + variables: Record = {}, + extraComponents: CatalogComponent[] = [] +) { + const directory = await tempDir(); + const events: Array> = []; + 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); + }); +}); diff --git a/src/main/engine/runtime.ts b/src/main/engine/runtime.ts index 86e9d56..9b7a7c4 100644 --- a/src/main/engine/runtime.ts +++ b/src/main/engine/runtime.ts @@ -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): Promise { 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(); - 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(); - 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 => { + 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): Promise { diff --git a/src/main/engine/types.ts b/src/main/engine/types.ts index f32865d..9e273b2 100644 --- a/src/main/engine/types.ts +++ b/src/main/engine/types.ts @@ -8,6 +8,7 @@ export interface BrowserLike { exists(selector: unknown, timeoutMs?: number): Promise; select(selector: unknown, value: string): Promise; runJs(script: string): Promise; + download(selector: unknown, folder: string, filename?: string, timeoutMs?: number): Promise; screenshot(path: string): Promise; currentUrl(): Promise; close(): Promise; diff --git a/src/main/flows.ts b/src/main/flows.ts index 182ef58..7225964 100644 --- a/src/main/flows.ts +++ b/src/main/flows.ts @@ -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 { // 忽略损坏文件,避免一个坏流程阻塞列表 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 0e8ef5b..2d063ee 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 = { + 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(null); - const [page, setPage] = useState("editor"); + const [page, setPage] = useState("tasks"); + const [flows, setFlows] = useState([]); + const [activeFlowId, setActiveFlowId] = useState(null); const [toast, setToast] = useState(null); const [error, setError] = useState(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 (
@@ -99,7 +189,7 @@ export function App() {
- {page === "editor" ? : page === "runs" ? : } + {page === "tasks" ? : page === "editor" ? : page === "runs" ? : } {PAGE_LABEL[page]}
@@ -116,19 +206,11 @@ export function App() {
); } diff --git a/src/renderer/src/canvas/FlowCanvas.tsx b/src/renderer/src/canvas/FlowCanvas.tsx index d1d8ecb..1d2ed1f 100644 --- a/src/renderer/src/canvas/FlowCanvas.tsx +++ b/src/renderer/src/canvas/FlowCanvas.tsx @@ -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 } 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 diff --git a/src/renderer/src/canvas/RpaNode.tsx b/src/renderer/src/canvas/RpaNode.tsx index f540aa1..b6bcac5 100644 --- a/src/renderer/src/canvas/RpaNode.tsx +++ b/src/renderer/src/canvas/RpaNode.tsx @@ -8,6 +8,7 @@ export interface RpaNodeData extends Record { 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(" ")} > - + @@ -41,7 +42,10 @@ export function RpaNode({ data, selected }: NodeProps) { {nodeData.label || component.name} {nodeData.subtitle || component.description || component.id}
- + + {nodeData.errorRouteEnabled && ( + + )} ); } diff --git a/src/renderer/src/pages/EditorPage.tsx b/src/renderer/src/pages/EditorPage.tsx index f3791ec..f101e33 100644 --- a/src/renderer/src/pages/EditorPage.tsx +++ b/src/renderer/src/pages/EditorPage.tsx @@ -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) {
+
+ + / + 编辑流程 +
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(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 ( +
+
+
+
自动化工作区 / 编排
+

编排

+

选择一个任务开始运行或编辑流程;组件库只在进入编辑时参与搭建。

+
+
+ + +
+
+ +
+
全部任务{flows.length}
+
已启用{enabledCount}
+
最近失败{failedCount}
+
本地执行离线
+
+ +
+
+
+
+ 任务列表 + 按最近修改时间排序 +
+ +
+ + + +
+ {filtered.map((flow) => ( +
setSelectedId(flow.id)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") setSelectedId(flow.id); + }} + > +
+ + {flow.name}{flow.nodeCount} 个节点 +
+ + {flow.enabled === false ? "未启用" : "已启用"} + + {flow.triggerLabel || "手动运行"} + {formatTime(flow.updatedAt)} + + + + + +
+ ))} + {!filtered.length && ( +
没有匹配的任务调整搜索条件,或新建一个自动化任务。
+ )} +
+
+ + +
+
+ ); +} diff --git a/src/renderer/src/panels/Palette.tsx b/src/renderer/src/panels/Palette.tsx index d154920..bd0e381 100644 --- a/src/renderer/src/panels/Palette.tsx +++ b/src/renderer/src/panels/Palette.tsx @@ -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("all"); - const [group, setGroup] = useState("all"); - const [scrollTop, setScrollTop] = useState(0); - const [viewport, setViewport] = useState(520); - const listRef = useRef(null); + const [expanded, setExpanded] = useState>(() => 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(() => { + const byGroup = new Map(); + 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 ( ); } -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 ( ); diff --git a/src/renderer/src/panels/Properties.tsx b/src/renderer/src/panels/Properties.tsx index 92e9872..fd4f057 100644 --- a/src/renderer/src/panels/Properties.tsx +++ b/src/renderer/src/panels/Properties.tsx @@ -375,6 +375,15 @@ function PropertyTab({ /> 记录异常堆栈 + + 开启后,节点下方显示可选的异常出口;红色仅用于真实运行失败。
); diff --git a/src/renderer/src/store/editorStore.ts b/src/renderer/src/store/editorStore.ts index e381c20..a11692d 100644 --- a/src/renderer/src/store/editorStore.ts +++ b/src/renderer/src/store/editorStore.ts @@ -33,7 +33,8 @@ function defaultOptions(): FlowNodeOptions { retryCount: 0, retryIntervalMs: 1000, skipOnError: false, - logException: true + logException: true, + errorRouteEnabled: false }; } diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 2530800..1073c2b 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -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; + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 109727a..2312791 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 {