feat: 接入系统能力并完善运行中心
This commit is contained in:
parent
aff2d5cd95
commit
f9688251ea
@ -50,6 +50,6 @@ docs 现状分析、实施方案
|
||||
|
||||
## 边界
|
||||
|
||||
- 1016 项目录中当前有 259 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。
|
||||
- 1016 项目录中当前有 275 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器、HTTP、Excel/CSV、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」,运行到该节点会明确失败。
|
||||
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
|
||||
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。
|
||||
|
||||
@ -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/循环/重试/跳过、主出口与异常出口语义、运行记录回写。未通过实际场景验证的变体仍不计入最终交付数量。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -10,5 +10,6 @@ import "./word";
|
||||
import "./message";
|
||||
import "./vision";
|
||||
import "./extended";
|
||||
import "./system-extended";
|
||||
|
||||
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";
|
||||
|
||||
51
src/main/capabilities/system-extended.test.ts
Normal file
51
src/main/capabilities/system-extended.test.ts
Normal file
@ -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<ReturnType<typeof spawn>> = [];
|
||||
const node = (componentId: string): FlowNode => ({ id: componentId, componentId, position: { x: 0, y: 0 }, values: {}, options: {} });
|
||||
async function run(id: string, args: Record<string, unknown> = {}) {
|
||||
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<number | null>((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();
|
||||
}
|
||||
});
|
||||
});
|
||||
132
src/main/capabilities/system-extended.ts
Normal file
132
src/main/capabilities/system-extended.ts
Normal file
@ -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<string, unknown>, 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<string, unknown>, 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, unknown>): 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<string, unknown>;
|
||||
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<string, string> = {
|
||||
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 };
|
||||
});
|
||||
48
src/main/flows.test.ts
Normal file
48
src/main/flows.test.ts
Normal file
@ -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: "测试失败原因"
|
||||
}));
|
||||
});
|
||||
});
|
||||
@ -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<string, string> = {
|
||||
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<string, string>();
|
||||
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 (
|
||||
<div className="runs">
|
||||
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div className="panel-head">
|
||||
<History className="icon" style={{ width: 15, height: 15 }} />
|
||||
<strong>运行记录</strong>
|
||||
<span className="spacer" />
|
||||
<span className="count-pill">0</span>
|
||||
</div>
|
||||
<section className="panel run-history-panel">
|
||||
<div className="panel-head"><History className="icon" /><strong>本次运行</strong><span className="spacer" /><span className="count-pill">{runId ? 1 : 0}</span></div>
|
||||
<div className="run-list">
|
||||
<div className="empty">
|
||||
<History className="icon" />
|
||||
<strong>还没有运行记录</strong>
|
||||
<span>执行引擎 Day 2 接入后,每次运行的状态、耗时、日志和截图会出现在这里。</span>
|
||||
</div>
|
||||
{runId || running || status ? (
|
||||
<div className="run-row active">
|
||||
<span className="run-row-icon">{running ? <Loader2 className="icon spin" /> : status === "succeeded" ? <CircleCheck className="icon" /> : <CircleAlert className="icon" />}</span>
|
||||
<span><span className="run-row-title">{flow?.name || "当前任务"}</span><span className="run-row-meta"><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span><span>{runId || "准备中"}</span></span></span>
|
||||
</div>
|
||||
) : <div className="empty"><History className="icon" /><strong>尚未开始运行</strong><span>从编排首页选择任务并点击“运行任务”。</span></div>}
|
||||
</div>
|
||||
<div className="run-page-actions">
|
||||
{running && <button type="button" className="btn btn-danger" onClick={() => void stop()} disabled={stopping}><Square className="icon" />{stopping ? "停止中" : "停止运行"}</button>}
|
||||
<button type="button" className="btn" onClick={onBack}><RotateCcw className="icon" />返回编排</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel run-detail">
|
||||
<div className="panel-head">
|
||||
<Play className="icon" style={{ width: 15, height: 15 }} />
|
||||
<strong>运行详情</strong>
|
||||
</div>
|
||||
<div className="panel-head"><Play className="icon" /><strong>节点进度</strong><span className="spacer" /><span className={`status-pill ${statusClass}`}>{STATUS_LABEL[effectiveStatus]}</span></div>
|
||||
<div className="run-detail-body">
|
||||
<div className="empty" style={{ minHeight: 360 }}>
|
||||
<Play className="icon" />
|
||||
<strong>未选择运行</strong>
|
||||
<span>左侧选择一条运行记录后,这里显示每个节点的状态、变量与证据。</span>
|
||||
<div className="run-facts">
|
||||
<div><span>已完成</span><strong>{completed} / {entries.length}</strong></div>
|
||||
<div><span>运行耗时</span><strong>{durationMs == null ? (running ? "运行中" : "—") : `${(durationMs / 1000).toFixed(2)} s`}</strong></div>
|
||||
</div>
|
||||
{error && <div className="run-error"><CircleAlert className="icon" /><div><strong>{error.reason}</strong>{error.recommendation && <span>{error.recommendation}</span>}</div></div>}
|
||||
<div className="run-node-table">
|
||||
{!entries.length && <div className="empty"><Play className="icon" /><strong>等待节点启动</strong><span>运行开始后,每个节点的状态和耗时会显示在这里。</span></div>}
|
||||
{entries.map(([nodeId, node]) => (
|
||||
<div className={`run-node-row ${node.status}`} key={nodeId}>
|
||||
<span className="run-node-marker" />
|
||||
<span className="run-node-main"><strong>{nameOf(nodeId)}</strong>{node.error && <small title={node.error.reason}>{node.error.reason}</small>}</span>
|
||||
<span className={`status-pill ${node.status === "succeeded" ? "done" : node.status === "failed" ? "failed" : node.status === "running" ? "running" : "paused"}`}>{STATUS_LABEL[node.status]}</span>
|
||||
<span className="run-node-duration">{node.durationMs == null ? "—" : `${node.durationMs} ms`}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div className="panel-head">
|
||||
<strong>快捷操作</strong>
|
||||
</div>
|
||||
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<button type="button" className="btn" onClick={onBack}>
|
||||
<RotateCcw className="icon" /> 返回流程编排
|
||||
</button>
|
||||
<div className="field-hint" style={{ lineHeight: "17px" }}>
|
||||
运行引擎完成后,这里会显示最近成功率、平均耗时和失败节点 TOP。
|
||||
</div>
|
||||
<section className="panel run-log-panel">
|
||||
<div className="panel-head"><Terminal className="icon" /><strong>运行日志</strong><span className="spacer" /><span className="count-pill">{logs.length}</span></div>
|
||||
<div className="run-page-logs">
|
||||
{!logs.length && <div className="empty"><Timer className="icon" /><strong>暂无日志</strong><span>节点执行、重试和异常信息会实时显示。</span></div>}
|
||||
{logs.map((log) => <div className={`run-page-log ${log.level}`} key={log.id}><span>{log.time}</span><strong>{log.nodeId ? nameOf(log.nodeId) : "流程"}</strong><p>{log.message}</p></div>)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user