254 lines
13 KiB
TypeScript
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);
|
||
}
|
||
|