Files
PRA-only-demo/src/main/flows.ts
T
yu f95a24458e 新增任务分享/导入功能并修复只读文件保存 EPERM
- flows: writeFlowFile 覆盖写加固(清只读属性->删除后 rename->覆盖写),
  外部复制导入的只读任务文件此前导致 flows:save EPERM 崩溃
- flows: exportFlowToPath/importFlowFromPath, 分享文件为单个 FlowDefinition
  JSON(.rpaflow.json); 导入校验 schemaVersion/nodes/edges, id 冲突时生成
  imported_ 前缀新 id 绝不覆盖既有任务
- IPC: flows:export 弹保存对话框 / flows:import 弹多选打开对话框,
  失败文件逐个汇报不阻塞其余
- preload: flows.export/flows.import; shared: FlowsImportResult 类型
- TasksPage: 头部「导入任务」按钮 + 每行「分享」按钮; App 接线与 toast
- 测试: 导出/导入往返、id 冲突、非法文件原因、只读覆盖共 4 例(7/7 绿)
2026-11-04 17:31:44 +08:00

368 lines
17 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 {
// RPA_DEMO_BROWSER 可强制指定(e2e/诊断用)
const forced = String(process.env.RPA_DEMO_BROWSER || "").trim().toLowerCase();
if (forced === "chrome" || forced === "edge") return forced;
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()
}
};
writeFlowFile(fileOf(flow.id), flow);
return flow;
}
/** 覆盖写任务文件。Windows 上目标文件带只读属性(外部导入/复制来的文件常见)或被占用时,
* rename 会抛 EPERM——这里依次尝试:清除只读属性后 rename → 删除后 rename → 直接覆盖写。 */
function writeFlowFile(target: string, flow: FlowDefinition): void {
const temp = `${target}.tmp`;
fs.writeFileSync(temp, JSON.stringify(flow, null, 2), "utf8");
const retryable = (error: unknown): boolean => {
const code = (error as NodeJS.ErrnoException)?.code;
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
};
const cleanupTemp = () => {
try {
fs.unlinkSync(temp);
} catch {
// 清理失败不影响主流程
}
};
try {
fs.renameSync(temp, target);
return;
} catch (error) {
if (!retryable(error)) {
cleanupTemp();
throw error;
}
}
try {
fs.chmodSync(target, 0o666);
} catch {
// 清只读属性失败不阻塞后续删除/覆盖尝试
}
try {
fs.unlinkSync(target);
} catch {
// 目标可能被其他进程占用,删除失败继续走覆盖写
}
try {
fs.renameSync(temp, target);
return;
} catch {
// rename 仍失败(目标被锁定),尝试直接覆盖
}
try {
fs.copyFileSync(temp, target);
cleanupTemp();
} catch (error) {
cleanupTemp();
throw new Error(
`任务文件被占用或无写入权限(${path.basename(target)})。请关闭可能打开该文件的编辑器/网盘同步后再试。`,
{ cause: error }
);
}
}
export function deleteFlow(id: string): void {
const target = fileOf(id);
if (fs.existsSync(target)) fs.unlinkSync(target);
}
/** 任务分享文件扩展名:单文件就是一个 FlowDefinition JSON,便于直接审阅/版本管理 */
const SHARE_EXTENSION = "rpaflow";
function normalizeImportedName(raw: unknown): string {
const name = String(raw ?? "").trim();
return name || "导入的任务";
}
/** 校验并归一化外部任务文件内容,不合法时抛出带原因的 Error */
function parseSharedFlow(raw: string, source: string): FlowDefinition {
let data: unknown;
try {
data = JSON.parse(raw);
} catch {
throw new Error(`${source}:不是有效的 JSON 文件`);
}
const flow = data as FlowDefinition;
if (!flow || typeof flow !== "object") throw new Error(`${source}:文件内容不是任务结构`);
if (flow.schemaVersion !== 1) throw new Error(`${source}:schemaVersion 不是 1(版本不兼容)`);
if (!Array.isArray(flow.nodes) || !Array.isArray(flow.edges)) {
throw new Error(`${source}:缺少 nodes/edges 结构`);
}
if (flow.nodes.length > 2000) throw new Error(`${source}:节点数超过上限 2000`);
return flow;
}
/** 从分享文件导入任务:id 冲突时生成新 id,避免覆盖既有任务 */
export function importFlowFromPath(filePath: string): FlowDefinition {
const raw = fs.readFileSync(filePath, "utf8");
const flow = parseSharedFlow(raw, path.basename(filePath));
const now = new Date().toISOString();
let id = typeof flow.id === "string" && ID_PATTERN.test(flow.id) ? flow.id : "";
if (!id || fs.existsSync(fileOf(id))) {
// 冲突/缺 id:基于名称生成可读 id,追加随机尾巴保证唯一
const seed = normalizeImportedName(flow.name)
.replace(/[^A-Za-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.slice(0, 40)
.toLowerCase();
const base = `imported_${seed || "task"}`;
id = `${base}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
while (!ID_PATTERN.test(id)) id = `imported_task_${Date.now().toString(36)}`;
}
const imported: FlowDefinition = {
...flow,
id,
name: normalizeImportedName(flow.name),
meta: { ...flow.meta, createdAt: flow.meta?.createdAt || now }
};
return saveFlow(imported);
}
/** 导出任务到指定路径(main 侧先用 dialog 拿到路径) */
export function exportFlowToPath(id: string, targetPath: string): string {
const flow = getFlow(id);
fs.writeFileSync(targetPath, JSON.stringify(flow, null, 2), "utf8");
return targetPath;
}
export { SHARE_EXTENSION };