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 { const result = new Map(); 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 = {}, 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); }