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

254 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}