feat: 画布式 RPA 桌面端 Day1(1016 目录/画布/容器/属性/持久化)
This commit is contained in:
commit
32ca0a1af8
34 files changed
+15141
No files matched your search
@@ -0,0 +1,62 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { Catalog, CatalogComponent } from "../shared/types";
|
||||
|
||||
let cached: Catalog | null = null;
|
||||
|
||||
function resolveCatalogPath(): string {
|
||||
const candidates = [
|
||||
process.env.RPA_CATALOG_PATH,
|
||||
path.join(process.resourcesPath, "catalog.json"),
|
||||
path.join(app.getAppPath(), "resources", "catalog.json"),
|
||||
path.join(__dirname, "../../resources/catalog.json")
|
||||
].filter(Boolean) as string[];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`catalog.json not found; tried: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 已注册执行器的函数名。Day 2 起由 capabilities 注册表汇总;
|
||||
* Day 1 先标出控制/触发与少量基础组件,保证目录状态可演示。
|
||||
*/
|
||||
const EXECUTABLE_FUNCTIONS = new Set<string>([
|
||||
"Trigger.Manual",
|
||||
"Trigger.Schedule",
|
||||
"Trigger.FileArrival",
|
||||
"logic_control_if_else",
|
||||
"logic_control_count_for",
|
||||
"logic_control_array_for_v3",
|
||||
"logic_control_while",
|
||||
"logic_control_try_catch",
|
||||
"logic_control_var",
|
||||
"logic_control_break_for",
|
||||
"logic_control_next_for",
|
||||
"logic_control_stop",
|
||||
"Basic_Delay_V2",
|
||||
"Basic_SetVariable",
|
||||
"Basic_DebugOutput"
|
||||
]);
|
||||
|
||||
export function catalogPath(): string {
|
||||
return resolveCatalogPath();
|
||||
}
|
||||
|
||||
export function loadCatalog(): Catalog {
|
||||
if (cached) return cached;
|
||||
const raw = fs.readFileSync(catalogPath(), "utf8");
|
||||
const catalog = JSON.parse(raw) as Catalog;
|
||||
catalog.components = catalog.components.map((component: CatalogComponent) => ({
|
||||
...component,
|
||||
implemented: EXECUTABLE_FUNCTIONS.has(component.functionName) || EXECUTABLE_FUNCTIONS.has(component.id)
|
||||
}));
|
||||
cached = catalog;
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export function markImplemented(functionNames: Iterable<string>): void {
|
||||
for (const name of functionNames) EXECUTABLE_FUNCTIONS.add(name);
|
||||
cached = null;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { app } from "electron";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { FlowDefinition, 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`);
|
||||
}
|
||||
|
||||
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: "",
|
||||
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() }
|
||||
};
|
||||
}
|
||||
|
||||
export function listFlows(): FlowSummary[] {
|
||||
const base = dir();
|
||||
const rows: FlowSummary[] = [];
|
||||
for (const entry of fs.readdirSync(base)) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
try {
|
||||
const flow = JSON.parse(fs.readFileSync(path.join(base, entry), "utf8")) as FlowDefinition;
|
||||
if (!flow?.id || !Array.isArray(flow.nodes)) continue;
|
||||
rows.push({
|
||||
id: flow.id,
|
||||
name: flow.name || "未命名流程",
|
||||
updatedAt: flow.meta?.updatedAt || new Date(0).toISOString(),
|
||||
nodeCount: flow.nodes.length
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, shell } from "electron";
|
||||
import path from "node:path";
|
||||
import type { AppInfo, FlowDefinition } from "../shared/types";
|
||||
import { catalogPath, loadCatalog } from "./catalog";
|
||||
import { createFlow, deleteFlow, getFlow, listFlows, saveFlow } from "./flows";
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 900,
|
||||
minWidth: 1200,
|
||||
minHeight: 760,
|
||||
show: false,
|
||||
backgroundColor: "#E7EDF3",
|
||||
title: "RPA Studio",
|
||||
titleBarStyle: "hidden",
|
||||
titleBarOverlay: {
|
||||
color: "#FFFFFF",
|
||||
symbolColor: "#4E5A6B",
|
||||
height: 58
|
||||
},
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "../preload/index.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false
|
||||
}
|
||||
});
|
||||
|
||||
mainWindow.on("ready-to-show", () => mainWindow?.show());
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:/i.test(url)) void shell.openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
|
||||
const devUrl = process.env.ELECTRON_RENDERER_URL;
|
||||
if (devUrl) {
|
||||
void mainWindow.loadURL(devUrl);
|
||||
} else {
|
||||
void mainWindow.loadFile(path.join(__dirname, "../renderer/index.html"));
|
||||
}
|
||||
}
|
||||
|
||||
function registerIpc(): void {
|
||||
ipcMain.handle("app:info", (): AppInfo => ({
|
||||
version: app.getVersion(),
|
||||
electron: process.versions.electron,
|
||||
chrome: process.versions.chrome,
|
||||
node: process.versions.node,
|
||||
userData: app.getPath("userData"),
|
||||
catalogPath: catalogPath(),
|
||||
packaged: app.isPackaged
|
||||
}));
|
||||
|
||||
ipcMain.handle("catalog:get", () => loadCatalog());
|
||||
ipcMain.handle("flows:list", () => listFlows());
|
||||
ipcMain.handle("flows:create", (_event, name?: string) => {
|
||||
const flow = createFlow(name);
|
||||
return saveFlow(flow);
|
||||
});
|
||||
ipcMain.handle("flows:get", (_event, id: string) => getFlow(id));
|
||||
ipcMain.handle("flows:save", (_event, flow: FlowDefinition) => saveFlow(flow));
|
||||
ipcMain.handle("flows:delete", (_event, id: string) => {
|
||||
deleteFlow(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:open", async (_event, options: { title?: string; filters?: Electron.FileFilter[]; directories?: boolean }) => {
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
title: options?.title || "选择文件",
|
||||
properties: options?.directories ? ["openDirectory"] : ["openFile"],
|
||||
filters: options?.filters
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0] || null;
|
||||
});
|
||||
|
||||
ipcMain.handle("dialog:save", async (_event, options: { title?: string; defaultPath?: string; filters?: Electron.FileFilter[] }) => {
|
||||
if (!mainWindow) return null;
|
||||
const result = await dialog.showSaveDialog(mainWindow, {
|
||||
title: options?.title || "保存文件",
|
||||
defaultPath: options?.defaultPath,
|
||||
filters: options?.filters
|
||||
});
|
||||
return result.canceled ? null : result.filePath || null;
|
||||
});
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc();
|
||||
createWindow();
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user