From f9688251eae362f66a0072b79f3a349e83a45966 Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 27 Oct 2026 11:55:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=B9=B6=E5=AE=8C=E5=96=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=B8=AD=E5=BF=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/02-实施方案.md | 2 +- src/main/capabilities/index.ts | 1 + src/main/capabilities/system-extended.test.ts | 51 +++++++ src/main/capabilities/system-extended.ts | 132 ++++++++++++++++++ src/main/flows.test.ts | 48 +++++++ src/renderer/src/pages/RunsPage.tsx | 99 ++++++++----- src/renderer/src/styles/app.css | 56 ++++++++ 8 files changed, 357 insertions(+), 34 deletions(-) create mode 100644 src/main/capabilities/system-extended.test.ts create mode 100644 src/main/capabilities/system-extended.ts create mode 100644 src/main/flows.test.ts diff --git a/README.md b/README.md index 33c789c..c110466 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,6 @@ docs 现状分析、实施方案 ## 边界 -- 1016 项目录中当前有 259 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。 +- 1016 项目录中当前有 275 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。 - 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。 - 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。 diff --git a/docs/02-实施方案.md b/docs/02-实施方案.md index 50173b1..b85fc26 100644 --- a/docs/02-实施方案.md +++ b/docs/02-实施方案.md @@ -4,7 +4,7 @@ > 依据:`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/循环/重试/跳过、主出口与异常出口语义。未通过实际场景验证的变体仍不计入最终交付数量。 +> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **275 项可执行 / 1016 项总目录**。测试覆盖计划能力审计、浏览器参数路由、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写。未通过实际场景验证的变体仍不计入最终交付数量。 --- diff --git a/src/main/capabilities/index.ts b/src/main/capabilities/index.ts index 9106125..15db955 100644 --- a/src/main/capabilities/index.ts +++ b/src/main/capabilities/index.ts @@ -10,5 +10,6 @@ import "./word"; import "./message"; import "./vision"; import "./extended"; +import "./system-extended"; export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry"; diff --git a/src/main/capabilities/system-extended.test.ts b/src/main/capabilities/system-extended.test.ts new file mode 100644 index 0000000..8a8b3c5 --- /dev/null +++ b/src/main/capabilities/system-extended.test.ts @@ -0,0 +1,51 @@ +import { spawn } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +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 "./system-extended"; + +const context = {} as RunContext; +const component = {} as CatalogComponent; +const children: Array> = []; +const node = (componentId: string): FlowNode => ({ id: componentId, componentId, position: { x: 0, y: 0 }, values: {}, options: {} }); +async function run(id: string, args: Record = {}) { + const handler = getHandler(id); + expect(handler, id).toBeTruthy(); + return handler!(context, node(id), args, component); +} + +afterEach(() => { + for (const child of children.splice(0)) if (!child.killed) child.kill(); + delete process.env.RPA_STUDIO_TEST_ENV; +}); + +describe("system extended capabilities", () => { + it("reads and writes process environment values and standard paths", async () => { + await expect(run("System_SetEnv", { name: "RPA_STUDIO_TEST_ENV", value: "connected" })).resolves.toBe("connected"); + await expect(run("System_GetEnv", { name: "RPA_STUDIO_TEST_ENV" })).resolves.toBe("connected"); + await expect(run("System_GetTempPath")).resolves.toBe(os.tmpdir()); + await expect(run("System_GetHomePath")).resolves.toBe(os.homedir()); + await expect(run("System_GetSystemPath", { path_name: "documents" })).resolves.toBe(path.join(os.homedir(), "Documents")); + }); + + it("finds and terminates a test child process by PID", async () => { + const child = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { windowsHide: true }); + children.push(child); + await new Promise((resolve) => setTimeout(resolve, 300)); + const pids = await run("App_GetPids", { pattern: "node.exe" }) as number[]; + expect(pids).toContain(child.pid); + const exited = new Promise((resolve) => child.once("exit", resolve)); + await run("App_Kill", { pid: child.pid }); + const exitCode = await exited; + expect(exitCode).not.toBeUndefined(); + }, 10000); + + it("registers safe clipboard and window variants", () => { + for (const id of ["Clipboard_GetTextV1", "Clipboard_SetTextV1", "Clipboard_ClearClipboard", "Window_GetWindowRect", "Window_SetWindowState", "Window_SetWindowState_v1", "Window_SizeWindow_v1"]) { + expect(getHandler(id), id).toBeTruthy(); + } + }); +}); diff --git a/src/main/capabilities/system-extended.ts b/src/main/capabilities/system-extended.ts new file mode 100644 index 0000000..e6675e4 --- /dev/null +++ b/src/main/capabilities/system-extended.ts @@ -0,0 +1,132 @@ +import os from "node:os"; +import path from "node:path"; +import { register } from "../engine/registry"; +import { RuntimeError, type CapabilityHandler } from "../engine/types"; +import { powershell, psString } from "./desktop"; + +function value(args: Record, keys: string[], fallback = ""): string { + for (const key of keys) { + const current = args[key]; + if (current !== undefined && current !== null && current !== "") return String(current); + } + return fallback; +} + +function required(args: Record, keys: string[], label: string, nodeId: string, componentId: string): string { + const result = value(args, keys).trim(); + if (!result) throw new RuntimeError("validation_error", `${label}为空`, { nodeId, componentId, recommendation: `请配置${label}` }); + return result; +} + +function processPattern(raw: string): string { + return path.basename(raw).replace(/\.exe$/i, "").replace(/\*/g, "*") || "*"; +} + +function selector(args: Record): string { + const raw = args.window_obj ?? args["窗口对象"] ?? args.match_title ?? args.select_title ?? args.target_window ?? args.title; + if (typeof raw === "object" && raw !== null) { + const object = raw as Record; + return String(object.title ?? object.windowTitle ?? object.pid ?? object.hwnd ?? ""); + } + return String(raw ?? ""); +} + +const WINDOW_API = `Add-Type @' +using System; +using System.Runtime.InteropServices; +public static class RpaWindowApi { + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags); +} +'@`; + +function findWindowScript(title: string): string { + return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`; +} + +register("System_GetEnv", (_ctx, node, args) => { + const name = required(args, ["name"], "环境变量名称", node.id, node.componentId); + return process.env[name] ?? ""; +}); + +register("System_SetEnv", (_ctx, node, args) => { + const name = required(args, ["name"], "环境变量名称", node.id, node.componentId); + const content = value(args, ["value"]); + process.env[name] = content; + return content; +}); + +register("System_GetTempPath", () => os.tmpdir()); +register("System_GetHomePath", () => os.homedir()); +register("System_GetSystemPath", (_ctx, _node, args) => { + const name = value(args, ["path_name"], "desktop").toLowerCase(); + const home = os.homedir(); + const paths: Record = { + desktop: path.join(home, "Desktop"), documents: path.join(home, "Documents"), document: path.join(home, "Documents"), + downloads: path.join(home, "Downloads"), download: path.join(home, "Downloads"), pictures: path.join(home, "Pictures"), + music: path.join(home, "Music"), videos: path.join(home, "Videos"), home, + temp: os.tmpdir(), system: process.env.SystemRoot || "C:\\Windows", appdata: process.env.APPDATA || path.join(home, "AppData", "Roaming") + }; + return paths[name] || value(args, ["path"]) || path.join(home, name); +}); + +const getPids: CapabilityHandler = async (_ctx, node, args) => { + const pattern = processPattern(required(args, ["pattern", "p_name", "name"], "进程名称", node.id, node.componentId)); + const output = await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Select-Object -ExpandProperty Id`); + return output.split(/\r?\n/).map(Number).filter(Number.isFinite); +}; +register("App_GetPids", getPids); +register("App_GetPid", async (ctx, node, args, component) => (await getPids(ctx, node, args, component) as number[])[0] ?? null); + +register(["App_Kill", "App_KillV1"], async (_ctx, node, args) => { + const pid = Number(value(args, ["pid"])); + const name = value(args, ["p_name", "pattern"]); + if (Number.isFinite(pid) && pid > 0) { + if (pid === process.pid) throw new RuntimeError("validation_error", "拒绝关闭 RPA Studio 自身进程", { nodeId: node.id, componentId: node.componentId }); + await powershell(`Stop-Process -Id ${pid} -Force -ErrorAction Stop`); + return pid; + } + const pattern = processPattern(required({ name }, ["name"], "进程名称", node.id, node.componentId)); + await powershell(`Get-Process | Where-Object { $_.ProcessName -like ${psString(pattern)} } | Stop-Process -Force -ErrorAction Stop`); + return pattern; +}); + +register("Clipboard_GetTextV1", () => powershell("Get-Clipboard -Raw")); +register("Clipboard_SetTextV1", async (_ctx, _node, args) => { + const content = value(args, ["text", "value"]); + await powershell(`Set-Clipboard -Value ${psString(content)}`); + return content; +}); +register("Clipboard_ClearClipboard", async () => { + await powershell("Set-Clipboard -Value $null"); + return true; +}); + +register("Window_GetWindowRect", async (_ctx, node, args) => { + const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId); + const output = await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top;title=$p.MainWindowTitle;pid=$p.Id} | ConvertTo-Json -Compress`); + return JSON.parse(output); +}); + +register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => { + const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId); + const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase(); + const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9; + await powershell(`${WINDOW_API}; ${findWindowScript(title)}; [RpaWindowApi]::ShowWindow($p.MainWindowHandle,${command}) | Out-Null`); + return state; +}); + +register("Window_SizeWindow_v1", async (_ctx, node, args) => { + const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId); + let size: unknown = args.window_size; + if (typeof size === "string") { + const source = size; + try { size = JSON.parse(source); } catch { size = source.split(/[,,xX ]+/).map(Number); } + } + const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0]; + if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId }); + await powershell(`${WINDOW_API}; ${findWindowScript(title)}; $r = New-Object RpaWindowApi+RECT; [RpaWindowApi]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; [RpaWindowApi]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,$r.Left,$r.Top,${width},${height},4) | Out-Null`); + return { width, height }; +}); diff --git a/src/main/flows.test.ts b/src/main/flows.test.ts new file mode 100644 index 0000000..8811a05 --- /dev/null +++ b/src/main/flows.test.ts @@ -0,0 +1,48 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const electronState = vi.hoisted(() => ({ userData: "" })); +vi.mock("electron", () => ({ + app: { + getPath: () => electronState.userData, + getVersion: () => "test" + } +})); + +import { createFlow, listFlows, saveFlow } from "./flows"; + +describe("flow summaries", () => { + beforeEach(async () => { + electronState.userData = await mkdtemp(path.join(os.tmpdir(), "rpa-flow-summary-")); + }); + + afterEach(async () => { + await rm(electronState.userData, { recursive: true, force: true }); + }); + + it("loads the latest persisted run into the task summary", async () => { + const flow = saveFlow(createFlow("运行记录任务")); + const olderDir = path.join(electronState.userData, "runs", "older"); + const latestDir = path.join(electronState.userData, "runs", "latest"); + await mkdir(olderDir, { recursive: true }); + await mkdir(latestDir, { recursive: true }); + await writeFile(path.join(olderDir, "run.json"), JSON.stringify({ + flowId: flow.id, status: "succeeded", finishedAt: "2026-10-27T01:00:00.000Z", durationMs: 120 + })); + await writeFile(path.join(latestDir, "run.json"), JSON.stringify({ + flowId: flow.id, status: "failed", finishedAt: "2026-10-27T02:00:00.000Z", durationMs: 456, + error: { reason: "测试失败原因" } + })); + + expect(listFlows()).toContainEqual(expect.objectContaining({ + id: flow.id, + name: "运行记录任务", + lastRunStatus: "failed", + lastRunAt: "2026-10-27T02:00:00.000Z", + lastRunDurationMs: 456, + lastRunError: "测试失败原因" + })); + }); +}); diff --git a/src/renderer/src/pages/RunsPage.tsx b/src/renderer/src/pages/RunsPage.tsx index 05e0787..fbb9054 100644 --- a/src/renderer/src/pages/RunsPage.tsx +++ b/src/renderer/src/pages/RunsPage.tsx @@ -1,49 +1,84 @@ -import { History, Play, RotateCcw } from "lucide-react"; +import { useMemo } from "react"; +import { CircleAlert, CircleCheck, History, Loader2, Play, RotateCcw, Square, Terminal, Timer } from "lucide-react"; +import { useEditorStore } from "../store/editorStore"; +import { useRunStore } from "../store/runStore"; + +const STATUS_LABEL: Record = { + running: "运行中", succeeded: "成功", failed: "失败", skipped: "已跳过", cancelled: "已取消", idle: "待运行" +}; export function RunsPage({ onBack }: { onBack: () => void }) { + const flow = useEditorStore((state) => state.flow); + const catalog = useEditorStore((state) => state.catalog); + const running = useRunStore((state) => state.running); + const stopping = useRunStore((state) => state.stopping); + const runId = useRunStore((state) => state.runId); + const nodes = useRunStore((state) => state.nodes); + const logs = useRunStore((state) => state.logs); + const status = useRunStore((state) => state.status); + const durationMs = useRunStore((state) => state.durationMs); + const error = useRunStore((state) => state.error); + const stop = useRunStore((state) => state.stop); + + const nameOf = useMemo(() => { + const map = new Map(); + for (const node of flow?.nodes || []) { + const component = catalog?.components.find((item) => item.id === node.componentId); + map.set(node.id, node.label || component?.name || node.componentId); + } + return (id: string) => map.get(id) || id; + }, [catalog, flow]); + + const entries = Object.entries(nodes); + const completed = entries.filter(([, state]) => !["idle", "running"].includes(state.status)).length; + const effectiveStatus = running ? "running" : status || "idle"; + const statusClass = effectiveStatus === "succeeded" ? "done" : effectiveStatus === "failed" ? "failed" : effectiveStatus === "cancelled" ? "paused" : effectiveStatus === "running" ? "running" : "pending"; + return (
-
-
- - 运行记录 - - 0 -
+
+
本次运行{runId ? 1 : 0}
-
- - 还没有运行记录 - 执行引擎 Day 2 接入后,每次运行的状态、耗时、日志和截图会出现在这里。 -
+ {runId || running || status ? ( +
+ {running ? : status === "succeeded" ? : } + {flow?.name || "当前任务"}{STATUS_LABEL[effectiveStatus]}{runId || "准备中"} +
+ ) :
尚未开始运行从编排首页选择任务并点击“运行任务”。
} +
+
+ {running && } +
-
- - 运行详情 -
+
节点进度{STATUS_LABEL[effectiveStatus]}
-
- - 未选择运行 - 左侧选择一条运行记录后,这里显示每个节点的状态、变量与证据。 +
+
已完成{completed} / {entries.length}
+
运行耗时{durationMs == null ? (running ? "运行中" : "—") : `${(durationMs / 1000).toFixed(2)} s`}
+
+ {error &&
{error.reason}{error.recommendation && {error.recommendation}}
} +
+ {!entries.length &&
等待节点启动运行开始后,每个节点的状态和耗时会显示在这里。
} + {entries.map(([nodeId, node]) => ( +
+ + {nameOf(nodeId)}{node.error && {node.error.reason}} + {STATUS_LABEL[node.status]} + {node.durationMs == null ? "—" : `${node.durationMs} ms`} +
+ ))}
-
-
- 快捷操作 -
-
- -
- 运行引擎完成后,这里会显示最近成功率、平均耗时和失败节点 TOP。 -
+
+
运行日志{logs.length}
+
+ {!logs.length &&
暂无日志节点执行、重试和异常信息会实时显示。
} + {logs.map((log) =>
{log.time}{log.nodeId ? nameOf(log.nodeId) : "流程"}

{log.message}

)}
diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index 69d8b45..559c104 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -1436,6 +1436,62 @@ textarea:focus-visible { padding: 14px; } +.run-history-panel, +.run-log-panel { + display: flex; + flex-direction: column; + min-height: 0; +} + +.run-page-actions { + padding: 12px; + display: flex; + flex-direction: column; + gap: 7px; + border-top: 1px solid var(--line); +} + +.run-page-actions .btn { justify-content: center; } + +.run-node-table { + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 10px; + overflow: hidden; +} + +.run-node-row { + min-height: 54px; + padding: 8px 10px; + display: grid; + grid-template-columns: 8px minmax(0, 1fr) auto 64px; + gap: 9px; + align-items: center; + border-bottom: 1px solid var(--line); + background: #fff; +} + +.run-node-row:last-child { border-bottom: 0; } +.run-node-marker { width: 7px; height: 7px; border-radius: 50%; background: var(--faint); } +.run-node-row.running .run-node-marker { background: var(--blue); } +.run-node-row.succeeded .run-node-marker { background: var(--success); } +.run-node-row.failed .run-node-marker { background: var(--danger); } +.run-node-row.skipped .run-node-marker, +.run-node-row.cancelled .run-node-marker { background: var(--warn); } + +.run-node-main { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.run-node-main strong { overflow: hidden; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; } +.run-node-main small { overflow: hidden; color: var(--danger); font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; } +.run-node-duration { color: var(--muted); font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; } + +.run-page-logs { min-height: 0; flex: 1; overflow: auto; padding: 8px; } +.run-page-log { padding: 8px; border-bottom: 1px solid var(--line); font-size: 10px; line-height: 15px; } +.run-page-log > span { color: var(--faint); font-family: var(--mono); } +.run-page-log > strong { display: block; margin-top: 2px; color: var(--ink-2); } +.run-page-log > p { margin-top: 2px; color: var(--muted); overflow-wrap: anywhere; } +.run-page-log.warn > p { color: var(--warn-deep); } +.run-page-log.error > p { color: var(--danger); } + .empty { height: 100%; min-height: 180px;