Files
PRA-only-demo/src/main/flows.ts
T

254 lines
13 KiB
TypeScript

import { app } from "electron";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { FlowDefinition, FlowEdge, FlowNode, FlowSummary } from "../shared/types";
const ID_PATTERN = /^[A-Za-z0-9_-]{3,80}$/;
function dir(): string {
const value = path.join(app.getPath("userData"), "flows");
fs.mkdirSync(value, { recursive: true });
return value;
}
function fileOf(id: string): string {
if (!ID_PATTERN.test(id)) throw new Error(`invalid flow id: ${id}`);
return path.join(dir(), `${id}.json`);
}
interface PersistedRunSummary {
flowId: string;
status: "succeeded" | "failed" | "cancelled";
finishedAt?: string;
startedAt?: string;
durationMs?: number;
error?: { reason?: string } | string;
}
function latestRuns(): Map<string, PersistedRunSummary> {
const result = new Map<string, PersistedRunSummary>();
const runsDir = path.join(app.getPath("userData"), "runs");
if (!fs.existsSync(runsDir)) return result;
for (const entry of fs.readdirSync(runsDir)) {
const file = path.join(runsDir, entry, "run.json");
if (!fs.existsSync(file)) continue;
try {
const run = JSON.parse(fs.readFileSync(file, "utf8")) as PersistedRunSummary;
if (!run.flowId || !run.status) continue;
const current = result.get(run.flowId);
const time = run.finishedAt || run.startedAt || "";
const currentTime = current?.finishedAt || current?.startedAt || "";
if (!current || time > currentTime) result.set(run.flowId, run);
} catch {
// 损坏的单次运行记录不应阻塞任务列表
}
}
return result;
}
export function createFlow(name = "未命名流程"): FlowDefinition {
const now = new Date().toISOString();
const id = `flow_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
return {
schemaVersion: 1,
id,
name,
description: "",
enabled: true,
trigger: { type: "manual", label: "手动运行" },
variables: [],
nodes: [
{
id: "node_start",
componentId: "Trigger.Manual",
position: { x: 160, y: 120 },
values: { entryName: "立即执行" },
options: { timeoutMs: 30000 }
}
],
edges: [],
meta: { createdAt: now, updatedAt: now, appVersion: app.getVersion() }
};
}
/** 演示流程浏览器类型:优先 Chrome,未安装时回退 Edge;两者都缺失则保持 chrome 由能力层给出友好错误 */
function demoBrowserType(): string {
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
const chromePaths = [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
path.join(localAppData, "Google", "Chrome", "Application", "chrome.exe")
];
if (chromePaths.some((p) => fs.existsSync(p))) return "chrome";
const edgePaths = [
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe"
];
if (edgePaths.some((p) => fs.existsSync(p))) return "edge";
return "chrome";
}
export function createDemoFlow(url: string): FlowDefinition {
const flow = createFlow("本地网页订单演示");
flow.description = "打开本地演示站,填写客户和金额,提交订单并读取结果。";
flow.nodes = [
flow.nodes[0],
{ id: "node_open", componentId: "WebBrowser_CreateV3", position: { x: 180, y: 250 }, values: { type: demoBrowserType(), url, isRemote: "0", openNewTab: "1", waitingload: "1" }, options: { timeoutMs: 30000 } },
{ id: "node_name", componentId: "Element_SetValue_V6", position: { x: 180, y: 390 }, values: { uiSelector: "css:#name", designated_info: "0", designated_obj: "", text: "RPA 客户", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
{ id: "node_amount", componentId: "Element_SetValue_V6", position: { x: 180, y: 530 }, values: { uiSelector: "css:#amount", designated_info: "0", designated_obj: "", text: "128", input_type: "0", isEnter: "0", interval: "0" }, options: { timeoutMs: 10000 } },
{ id: "node_submit", componentId: "Element_Click_V6", position: { x: 180, y: 670 }, values: { uiSelector: "css:#submit", designated_info: "0", designated_obj: "", clickway: "0", clickfunc: "0", operation: "1" }, options: { timeoutMs: 10000 } },
{ id: "node_result", componentId: "Element_GetText_V5", position: { x: 180, y: 810 }, values: { choose: "0", uiSelector: "css:#result", cvSelector: "", designated_info: "0", designated_obj: "", attr_type: "default", attr_name: "" }, options: { timeoutMs: 10000, outputVar: "订单结果" } }
];
flow.edges = [
{ id: "edge_start_open", source: "node_start", target: "node_open" },
{ id: "edge_open_name", source: "node_open", target: "node_name" },
{ id: "edge_name_amount", source: "node_name", target: "node_amount" },
{ id: "edge_amount_submit", source: "node_amount", target: "node_submit" },
{ id: "edge_submit_result", source: "node_submit", target: "node_result" }
];
return flow;
}
function exampleFlow(id: string, name: string, description: string): FlowDefinition {
const flow = createFlow(name);
flow.id = id;
flow.description = description;
flow.nodes = [];
flow.edges = [];
return flow;
}
function exampleNode(id: string, componentId: string, y: number, values: Record<string, unknown> = {}, outputVar?: string): FlowNode {
return { id, componentId, position: { x: 220, y }, values, options: { timeoutMs: 30000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, outputVar }, enabled: true };
}
function chain(nodes: FlowNode[]): FlowEdge[] {
return nodes.slice(0, -1).map((node, index) => ({ id: `edge_${node.id}_${nodes[index + 1].id}`, source: node.id, target: nodes[index + 1].id }));
}
export function createExampleFlows(url: string): FlowDefinition[] {
const browser = createDemoFlow(url);
browser.id = "example_browser_order";
browser.name = "示例 1 · 本地网页订单";
const file = exampleFlow("example_file_text", "示例 2 · 文件与文本处理", "在临时目录写入文本、读取、转换为大写并写入结果文件。");
file.nodes = [
exampleNode("file_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
exampleNode("file_temp", "System_GetTempPath", 210, {}, "tempPath"),
exampleNode("file_write", "File_Write", 340, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", content: "hello rpa studio", encode: "utf-8", append: "False" }),
exampleNode("file_read", "File_Read", 470, { path: "${tempPath}\\RPA-Studio-Demo\\input.txt", encode: "utf-8" }, "sourceText"),
exampleNode("file_upper", "StrUtil_UpperCase", 600, { string: "${sourceText}" }, "upperText"),
exampleNode("file_result", "File_Write", 730, { path: "${tempPath}\\RPA-Studio-Demo\\result.txt", content: "${upperText}", encode: "utf-8", append: "False" }),
exampleNode("file_log", "Basic_DebugOutput", 860, { msg: "处理完成:${upperText}" })
]; file.edges = chain(file.nodes);
const excel = exampleFlow("example_excel_orders", "示例 3 · Excel 订单读写", "在临时目录创建 Excel,写入订单数据并读取验证。");
excel.nodes = [
exampleNode("excel_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
exampleNode("excel_temp", "System_GetTempPath", 210, {}, "tempPath"),
exampleNode("excel_new", "Excel_Cs_NewV5", 340, { path: "${tempPath}\\RPA-Studio-Demo", name: "orders.xlsx", override: "1", show: "0" }, "workbook"),
exampleNode("excel_write", "Excel_Cs_WriteRange", 470, { workbook: "${workbook}", sheet: "Sheet1", row: "1", column: "A", contents: '[["客户","金额","状态"],["张三",128,"已创建"],["李四",256,"已创建"]]', save: "1" }),
exampleNode("excel_read", "Excel_Cs_ReadRangeV2", 600, { workbook: "${workbook}", sheet: "Sheet1", range: "A1:C3", decimal: "0" }, "excelData"),
exampleNode("excel_log", "Basic_DebugOutput", 730, { msg: "Excel内容:${excelData}" })
]; excel.edges = chain(excel.nodes);
const clipboard = exampleFlow("example_clipboard", "示例 4 · 剪贴板往返", "写入并读取 Windows 剪贴板文本。");
clipboard.nodes = [
exampleNode("clip_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
exampleNode("clip_set", "Clipboard_SetText", 230, { text: "RPA Studio 剪贴板测试" }),
exampleNode("clip_get", "Clipboard_GetText", 380, {}, "clipboardText"),
exampleNode("clip_log", "Basic_DebugOutput", 530, { msg: "剪贴板内容:${clipboardText}" })
]; clipboard.edges = chain(clipboard.nodes);
const word = exampleFlow("example_word_document", "示例 5 · Word 文档生成", "在临时目录创建 Word 文档、写入文本并读取验证,需要本机安装 Word。");
word.nodes = [
exampleNode("word_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
exampleNode("word_temp", "System_GetTempPath", 200, {}, "tempPath"),
exampleNode("word_folder", "File_MakeDir", 320, { folder_path: "${tempPath}\\RPA-Studio-Demo" }),
exampleNode("word_new", "Word_New_V1", 440, { filePath: "${tempPath}\\RPA-Studio-Demo", name: "demo.docx", visible: "False" }, "wordDoc"),
exampleNode("word_write", "Word_Write", 560, { objWord: "${wordDoc}", text: "RPA Studio Word 自动化测试" }),
exampleNode("word_read", "Word_ReadAll", 680, { objWord: "${wordDoc}" }, "wordText"),
exampleNode("word_log", "Basic_DebugOutput", 800, { msg: "Word内容:${wordText}" })
]; word.edges = chain(word.nodes);
const error = exampleFlow("example_error_route", "示例 6 · 异常分支", "故意读取不存在文件,验证右侧异常出口和错误变量。");
error.nodes = [
exampleNode("error_start", "Trigger.Manual", 80, { entryName: "立即执行" }),
{ ...exampleNode("error_fail", "File_Read", 230, { path: "Z:\\RPA-DEMO-NOT-FOUND.txt", encode: "utf-8" }), options: { timeoutMs: 5000, retryCount: 0, retryIntervalMs: 1000, skipOnError: false, logException: true, errorRouteEnabled: true } },
exampleNode("error_normal", "Basic_DebugOutput", 390, { msg: "不应执行到正常出口" }),
{ ...exampleNode("error_recover", "Basic_DebugOutput", 230, { msg: "异常已捕获:${error.reason}" }), position: { x: 560, y: 230 } }
]; error.edges = [
{ id: "edge_error_start", source: "error_start", target: "error_fail" },
{ id: "edge_error_normal", source: "error_fail", target: "error_normal" },
{ id: "edge_error_recover", source: "error_fail", sourceHandle: "error", target: "error_recover" }
];
return [browser, file, excel, clipboard, word, error];
}
export function listFlows(): FlowSummary[] {
const base = dir();
const rows: FlowSummary[] = [];
const runs = latestRuns();
for (const entry of fs.readdirSync(base)) {
if (!entry.endsWith(".json")) continue;
if (/-pre-|\\.bak/i.test(entry)) continue;
try {
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
const lastRun = runs.get(flow.id);
rows.push({
id: flow.id,
name: flow.name || "未命名流程",
updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(),
nodeCount: flow.nodes.length,
enabled: flow.enabled !== false,
triggerLabel: flow.trigger?.label || "手动运行",
lastRunAt: lastRun?.finishedAt || lastRun?.startedAt,
lastRunStatus: lastRun?.status || "never",
lastRunDurationMs: lastRun?.durationMs,
lastRunError: typeof lastRun?.error === "string" ? lastRun.error : lastRun?.error?.reason
});
} catch {
// 忽略损坏文件,避免一个坏流程阻塞列表
}
}
return rows.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
}
export function getFlow(id: string): FlowDefinition {
const raw = fs.readFileSync(fileOf(id), "utf8");
const flow = JSON.parse(raw) as FlowDefinition;
if (!flow || flow.schemaVersion !== 1 || !Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) {
throw new Error(`invalid flow file: ${id}`);
}
return flow;
}
export function saveFlow(input: FlowDefinition): FlowDefinition {
if (!input || input.schemaVersion !== 1) throw new Error("invalid flow schemaVersion");
if (!Array.isArray(input.nodes) || !Array.isArray(input.edges)) throw new Error("invalid flow structure");
if (input.nodes.length > 2000) throw new Error("flow has too many nodes");
const now = new Date().toISOString();
const flow: FlowDefinition = {
...input,
meta: {
createdAt: input.meta?.createdAt || now,
updatedAt: now,
appVersion: app.getVersion()
}
};
const target = fileOf(flow.id);
const temp = `${target}.tmp`;
fs.writeFileSync(temp, JSON.stringify(flow, null, 2), "utf8");
fs.renameSync(temp, target);
return flow;
}
export function deleteFlow(id: string): void {
const target = fileOf(id);
if (fs.existsSync(target)) fs.unlinkSync(target);
}