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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { AppInfo, Catalog, FlowDefinition, FlowSummary } from "../shared/types";
|
||||
|
||||
const api = {
|
||||
app: {
|
||||
info: (): Promise<AppInfo> => ipcRenderer.invoke("app:info")
|
||||
},
|
||||
catalog: {
|
||||
get: (): Promise<Catalog> => ipcRenderer.invoke("catalog:get")
|
||||
},
|
||||
flows: {
|
||||
list: (): Promise<FlowSummary[]> => ipcRenderer.invoke("flows:list"),
|
||||
create: (name?: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:create", name),
|
||||
get: (id: string): Promise<FlowDefinition> => ipcRenderer.invoke("flows:get", id),
|
||||
save: (flow: FlowDefinition): Promise<FlowDefinition> => ipcRenderer.invoke("flows:save", flow),
|
||||
delete: (id: string): Promise<boolean> => ipcRenderer.invoke("flows:delete", id)
|
||||
},
|
||||
dialog: {
|
||||
open: (options?: { title?: string; filters?: Array<{ name: string; extensions: string[] }>; directories?: boolean }): Promise<string | null> =>
|
||||
ipcRenderer.invoke("dialog:open", options || {}),
|
||||
save: (options?: { title?: string; defaultPath?: string; filters?: Array<{ name: string; extensions: string[] }> }): Promise<string | null> =>
|
||||
ipcRenderer.invoke("dialog:save", options || {})
|
||||
}
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld("rpa", api);
|
||||
|
||||
export type RpaApi = typeof api;
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:;" />
|
||||
<title>RPA Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Bot, CircleAlert, Loader2, Sparkles } from "lucide-react";
|
||||
import type { Catalog } from "@shared/types";
|
||||
import { useEditorStore } from "./store/editorStore";
|
||||
import { EditorPage } from "./pages/EditorPage";
|
||||
import { RunsPage } from "./pages/RunsPage";
|
||||
import { SettingsPage } from "./pages/SettingsPage";
|
||||
import { railIcons } from "./components/icons";
|
||||
|
||||
type Page = "editor" | "runs" | "settings";
|
||||
|
||||
interface ToastState {
|
||||
id: number;
|
||||
message: string;
|
||||
kind: "ok" | "error";
|
||||
}
|
||||
|
||||
const PAGE_LABEL: Record<Page, string> = {
|
||||
editor: "流程编排",
|
||||
runs: "运行中心",
|
||||
settings: "设置"
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const [catalog, setCatalog] = useState<Catalog | null>(null);
|
||||
const [page, setPage] = useState<Page>("editor");
|
||||
const [toast, setToast] = useState<ToastState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const setCatalogStore = useEditorStore((state) => state.setCatalog);
|
||||
const loadFlow = useEditorStore((state) => state.loadFlow);
|
||||
|
||||
const showToast = useCallback((message: string, kind: "ok" | "error" = "ok") => {
|
||||
setToast({ id: Date.now(), message, kind });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toast) return;
|
||||
const timer = window.setTimeout(() => setToast(null), 3200);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const boot = async () => {
|
||||
try {
|
||||
const loaded = await window.rpa.catalog.get();
|
||||
if (cancelled) return;
|
||||
setCatalogStore(loaded);
|
||||
setCatalog(loaded);
|
||||
const flows = await window.rpa.flows.list();
|
||||
const flow = flows.length ? await window.rpa.flows.get(flows[0].id) : await window.rpa.flows.create("网页订单录入");
|
||||
if (!cancelled) loadFlow(flow);
|
||||
} catch (bootError) {
|
||||
if (!cancelled) setError(bootError instanceof Error ? bootError.message : String(bootError));
|
||||
}
|
||||
};
|
||||
void boot();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadFlow, setCatalogStore]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="loading" style={{ padding: 40 }}>
|
||||
<CircleAlert className="icon" style={{ color: "var(--danger)", width: 28, height: 28 }} />
|
||||
<strong style={{ color: "var(--ink)" }}>启动失败</strong>
|
||||
<span style={{ maxWidth: 520, textAlign: "center", lineHeight: "18px" }}>{error}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!catalog) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<Loader2 className="icon spin" style={{ width: 24, height: 24 }} />
|
||||
正在加载 1016 项组件目录…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const executable = catalog.components.filter((item) => item.implemented).length;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="titlebar">
|
||||
<div className="brand">
|
||||
<span className="logo">
|
||||
<Bot className="icon" />
|
||||
</span>
|
||||
<strong>RPA Studio</strong>
|
||||
</div>
|
||||
<span className="titlebar-divider" />
|
||||
<span className="workspace-chip">
|
||||
{page === "editor" ? <Sparkles className="icon" /> : page === "runs" ? <railIcons.runs className="icon" /> : <railIcons.settings className="icon" />}
|
||||
{PAGE_LABEL[page]}
|
||||
</span>
|
||||
<div className="titlebar-right">
|
||||
<span className="engine-chip" title="运行期完全离线">
|
||||
<span className="engine-dot" />
|
||||
本地执行 · 离线
|
||||
</span>
|
||||
<span className="engine-chip">
|
||||
{catalog.counts.total} 目录 · {executable} 可执行
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav className="rail" aria-label="主导航">
|
||||
<button
|
||||
type="button"
|
||||
className={`rail-item${page === "editor" ? " active" : ""}`}
|
||||
onClick={() => setPage("editor")}
|
||||
>
|
||||
<railIcons.editor className="icon" />
|
||||
流程
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rail-item${page === "runs" ? " active" : ""}`}
|
||||
onClick={() => setPage("runs")}
|
||||
>
|
||||
<railIcons.runs className="icon" />
|
||||
运行
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`rail-item${page === "settings" ? " active" : ""}`}
|
||||
onClick={() => setPage("settings")}
|
||||
>
|
||||
<railIcons.settings className="icon" />
|
||||
设置
|
||||
</button>
|
||||
<span className="rail-spacer" />
|
||||
<div className="rail-user">
|
||||
<span className="rail-avatar">RA</span>
|
||||
<span className="rail-name">本机用户</span>
|
||||
<span className="rail-role">离线工作区</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main className="main">
|
||||
{page === "editor" && <EditorPage catalog={catalog} onToast={showToast} />}
|
||||
{page === "runs" && <RunsPage onBack={() => setPage("editor")} />}
|
||||
{page === "settings" && <SettingsPage catalog={catalog} />}
|
||||
</main>
|
||||
|
||||
{toast && (
|
||||
<div className={`toast${toast.kind === "error" ? " error" : ""}`}>
|
||||
{toast.kind === "error" ? <CircleAlert className="icon" /> : <Bot className="icon" />}
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { ArrowDown, ArrowUp, Boxes, Plus, Trash2 } from "lucide-react";
|
||||
import type { CatalogComponent, FlowNode } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { groupIcon } from "../components/icons";
|
||||
|
||||
export interface ContainerSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export interface ContainerNodeData extends Record<string, unknown> {
|
||||
component: CatalogComponent;
|
||||
node: FlowNode;
|
||||
slots: ContainerSlot[];
|
||||
childrenBySlot: Record<string, FlowNode[]>;
|
||||
components: Map<string, CatalogComponent>;
|
||||
selectedChildId?: string | null;
|
||||
}
|
||||
|
||||
export function containerSlots(component: CatalogComponent): ContainerSlot[] {
|
||||
switch (component.id) {
|
||||
case "logic_control_if_else":
|
||||
return [
|
||||
{ key: "statement_if", label: "条件为真", hint: "拖入真分支步骤" },
|
||||
{ key: "statement_else", label: "条件为假", hint: "拖入假分支步骤" }
|
||||
];
|
||||
case "logic_control_count_for":
|
||||
return [{ key: "count_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
|
||||
case "logic_control_array_for_v3":
|
||||
return [{ key: "array_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
|
||||
case "logic_control_dictionary_for_v1":
|
||||
return [{ key: "dictionary_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
|
||||
case "logic_control_while":
|
||||
return [{ key: "if_for_fn", label: "循环体", hint: "拖入循环体步骤" }];
|
||||
case "infinite_loop":
|
||||
return [{ key: "loop_body", label: "循环体", hint: "拖入循环体步骤" }];
|
||||
case "logic_control_try_catch":
|
||||
return [
|
||||
{ key: "try_fn", label: "Try", hint: "拖入受保护步骤" },
|
||||
{ key: "except_fn", label: "Catch", hint: "拖入异常处理步骤" },
|
||||
{ key: "else_fn", label: "Else", hint: "拖入无异常步骤" }
|
||||
];
|
||||
case "Element_exception_monitor_v2":
|
||||
return [{ key: "exception_monitor_params", label: "监控体", hint: "拖入监控步骤" }];
|
||||
default: {
|
||||
const key = component.slots?.[0] || "body";
|
||||
return [{ key, label: "步骤", hint: "拖入步骤" }];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ContainerNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as ContainerNodeData;
|
||||
const { component, node, slots, childrenBySlot, components, selectedChildId } = nodeData;
|
||||
const addChild = useEditorStore((state) => state.addChild);
|
||||
const selectNode = useEditorStore((state) => state.selectNode);
|
||||
const removeNode = useEditorStore((state) => state.removeNode);
|
||||
const moveChild = useEditorStore((state) => state.moveChild);
|
||||
const Icon = groupIcon(component.group);
|
||||
const pending = !component.implemented;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"container-node",
|
||||
`tone-${component.group}`,
|
||||
selected ? "selected" : "",
|
||||
pending ? "pending" : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
onClick={() => selectNode(node.id)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} />
|
||||
<div className="container-head">
|
||||
<span className="container-icon">
|
||||
<Icon className="icon" />
|
||||
</span>
|
||||
<span className="container-copy">
|
||||
<span className="container-kicker">容器 · {component.id}</span>
|
||||
<strong>{node.label || component.name}</strong>
|
||||
</span>
|
||||
{pending && <span className="pending-badge">待接入</span>}
|
||||
</div>
|
||||
|
||||
<div className="container-body">
|
||||
{slots.map((slot) => {
|
||||
const children = childrenBySlot[slot.key] || [];
|
||||
return (
|
||||
<section className="container-slot" key={slot.key}>
|
||||
<div className="container-slot-head">
|
||||
<span>{slot.label}</span>
|
||||
<span className="container-slot-count">{children.length}</span>
|
||||
</div>
|
||||
|
||||
{children.map((child, index) => {
|
||||
const childComponent = components.get(child.componentId);
|
||||
const ChildIcon = groupIcon(childComponent?.group || "data");
|
||||
const childPending = childComponent ? !childComponent.implemented : true;
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`container-child${selectedChildId === child.id ? " selected" : ""}${
|
||||
childPending ? " pending" : ""
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
selectNode(child.id);
|
||||
}}
|
||||
>
|
||||
<span className="container-child-index">{index + 1}</span>
|
||||
<span className="container-child-icon">
|
||||
<ChildIcon className="icon" />
|
||||
</span>
|
||||
<span className="container-child-copy">
|
||||
<span className="container-child-name">{child.label || childComponent?.name || child.componentId}</span>
|
||||
<span className="container-child-id">{child.componentId}</span>
|
||||
</span>
|
||||
{child.options?.outputVar && <span className="container-child-var">{child.options.outputVar}</span>}
|
||||
<span className="container-child-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="上移"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
moveChild(child.id, -1);
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="下移"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
moveChild(child.id, 1);
|
||||
}}
|
||||
>
|
||||
<ArrowDown className="icon" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-btn"
|
||||
title="删除"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
removeNode(child.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="icon" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div
|
||||
className="container-drop nodrag"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopPropagation();
|
||||
const componentId = event.dataTransfer.getData("application/rpa-component");
|
||||
if (componentId) addChild(node.id, slot.key, componentId);
|
||||
}}
|
||||
>
|
||||
<Plus className="icon" />
|
||||
{slot.hint}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Node,
|
||||
type NodeChange
|
||||
} from "@xyflow/react";
|
||||
import type { Catalog, CatalogComponent } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { RpaNode, type RpaNodeData } from "./RpaNode";
|
||||
import { ContainerNode, containerSlots, type ContainerNodeData } from "./ContainerNode";
|
||||
import { MousePointerClick } from "lucide-react";
|
||||
|
||||
const nodeTypes = { rpa: RpaNode, container: ContainerNode };
|
||||
|
||||
const GROUP_COLORS: Record<string, string> = {
|
||||
control: "#4b5bd6",
|
||||
trigger: "#b45309",
|
||||
app: "#2264e5",
|
||||
web: "#0e7b6b",
|
||||
office: "#b56a08",
|
||||
data: "#2264e5",
|
||||
file: "#52627a",
|
||||
desktop: "#6b46c1",
|
||||
vision: "#3f5fd0",
|
||||
message: "#0e7b6b",
|
||||
mobile: "#7a4bc1"
|
||||
};
|
||||
|
||||
function subtitleFor(component: Catalog["components"][number], values: Record<string, unknown>): string {
|
||||
const field = component.fields.find((item) => item.widget !== "hidden" && item.widget !== "region");
|
||||
if (field) {
|
||||
const raw = values[field.key];
|
||||
const text = raw === undefined || raw === null || raw === "" ? "" : String(raw);
|
||||
if (text) return `${field.label}:${text.length > 28 ? `${text.slice(0, 28)}…` : text}`;
|
||||
}
|
||||
return component.description || component.id;
|
||||
}
|
||||
|
||||
function CanvasInner({ catalog, onAddAt }: { catalog: Catalog; onAddAt: (componentId: string, position: { x: number; y: number }) => void }) {
|
||||
const flow = useEditorStore((state) => state.flow);
|
||||
const selectedNodeId = useEditorStore((state) => state.selectedNodeId);
|
||||
const selectNode = useEditorStore((state) => state.selectNode);
|
||||
const moveNode = useEditorStore((state) => state.moveNode);
|
||||
const removeNode = useEditorStore((state) => state.removeNode);
|
||||
const addEdge = useEditorStore((state) => state.addEdge);
|
||||
const removeEdge = useEditorStore((state) => state.removeEdge);
|
||||
const { screenToFlowPosition, fitView } = useReactFlow();
|
||||
|
||||
const componentMap = useMemo(() => new Map(catalog.components.map((item) => [item.id, item])), [catalog]);
|
||||
|
||||
const nodes = useMemo<Node[]>(() => {
|
||||
if (!flow) return [];
|
||||
return flow.nodes
|
||||
.filter((node) => !node.parentId)
|
||||
.map((node) => {
|
||||
const component = componentMap.get(node.componentId);
|
||||
if (component && component.slots.length > 0) {
|
||||
const slots = containerSlots(component);
|
||||
const childrenBySlot: Record<string, typeof flow.nodes> = {};
|
||||
let rowCount = 0;
|
||||
for (const slot of slots) {
|
||||
childrenBySlot[slot.key] = flow.nodes.filter(
|
||||
(item) => item.parentId === node.id && item.slot === slot.key
|
||||
);
|
||||
rowCount += childrenBySlot[slot.key].length;
|
||||
}
|
||||
const height = 58 + slots.length * 26 + rowCount * 46 + slots.length * 35 + 16;
|
||||
const data: ContainerNodeData = {
|
||||
component,
|
||||
node,
|
||||
slots,
|
||||
childrenBySlot,
|
||||
components: componentMap,
|
||||
selectedChildId: selectedNodeId
|
||||
};
|
||||
return {
|
||||
id: node.id,
|
||||
type: "container",
|
||||
position: node.position,
|
||||
selected: node.id === selectedNodeId,
|
||||
width: 306,
|
||||
height,
|
||||
style: { width: 306, height },
|
||||
data: data as unknown as Record<string, unknown>
|
||||
} as Node;
|
||||
}
|
||||
const fallback: CatalogComponent = {
|
||||
id: node.componentId,
|
||||
name: node.componentId,
|
||||
kind: "vendor",
|
||||
group: "data",
|
||||
categories: [],
|
||||
categorySignature: "",
|
||||
description: "",
|
||||
messageTemplate: "",
|
||||
baseType: node.componentId,
|
||||
functionName: "",
|
||||
functionCalls: [],
|
||||
version: "",
|
||||
tags: [],
|
||||
platformTag: null,
|
||||
icon: "data",
|
||||
risk: "low",
|
||||
fields: [],
|
||||
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
|
||||
slots: [],
|
||||
implemented: false
|
||||
};
|
||||
return {
|
||||
id: node.id,
|
||||
type: "rpa",
|
||||
position: node.position,
|
||||
selected: node.id === selectedNodeId,
|
||||
width: 258,
|
||||
height: 82,
|
||||
data: {
|
||||
component: component || fallback,
|
||||
label: node.label,
|
||||
subtitle: component ? subtitleFor(component, node.values) : undefined,
|
||||
outputVar: node.options?.outputVar
|
||||
} as RpaNodeData as unknown as Record<string, unknown>
|
||||
} as Node;
|
||||
});
|
||||
}, [flow, componentMap, selectedNodeId]);
|
||||
|
||||
const edges = useMemo<Edge[]>(() => {
|
||||
if (!flow) return [];
|
||||
const topLevel = new Set(flow.nodes.filter((node) => !node.parentId).map((node) => node.id));
|
||||
return flow.edges
|
||||
.filter((edge) => topLevel.has(edge.source) && topLevel.has(edge.target))
|
||||
.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle || undefined,
|
||||
targetHandle: edge.targetHandle || undefined,
|
||||
type: "smoothstep",
|
||||
markerEnd: { type: MarkerType.ArrowClosed, width: 14, height: 14, color: "#b8c5d6" }
|
||||
}));
|
||||
}, [flow]);
|
||||
|
||||
const handleNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
for (const change of changes) {
|
||||
if (change.type === "position" && change.position) {
|
||||
moveNode(change.id, change.position);
|
||||
} else if (change.type === "remove") {
|
||||
removeNode(change.id);
|
||||
} else if (change.type === "select") {
|
||||
if (change.selected) selectNode(change.id);
|
||||
else if (selectedNodeId === change.id) selectNode(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveNode, removeNode, selectNode, selectedNodeId]
|
||||
);
|
||||
|
||||
const handleEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
for (const change of changes) if (change.type === "remove") removeEdge(change.id);
|
||||
},
|
||||
[removeEdge]
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
addEdge({
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle,
|
||||
targetHandle: connection.targetHandle
|
||||
});
|
||||
},
|
||||
[addEdge]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!flow) return;
|
||||
const timer = window.setTimeout(() => fitView({ padding: 0.3, duration: 240 }), 80);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [flow?.id, fitView]);
|
||||
|
||||
if (!flow) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flow-canvas"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
const componentId = event.dataTransfer.getData("application/rpa-component");
|
||||
if (!componentId) return;
|
||||
const position = screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
onAddAt(componentId, position);
|
||||
}}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onPaneClick={() => selectNode(null)}
|
||||
onNodeClick={(_event, node) => selectNode(node.id)}
|
||||
defaultEdgeOptions={{ type: "smoothstep" }}
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
minZoom={0.3}
|
||||
maxZoom={2}
|
||||
fitView
|
||||
proOptions={{ hideAttribution: true }}
|
||||
snapToGrid
|
||||
snapGrid={[10, 10]}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1.2} color="rgba(138,153,172,.34)" />
|
||||
<Controls position="bottom-right" showInteractive={false} />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
style={{ width: 132, height: 92, marginBottom: 44 }}
|
||||
nodeColor={(node) => GROUP_COLORS[(node.data as RpaNodeData)?.component?.group || "data"] || "#2264e5"}
|
||||
maskColor="rgba(242,245,248,.72)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
|
||||
{flow.nodes.length <= 1 && (
|
||||
<div className="canvas-hint">
|
||||
<MousePointerClick className="icon" />
|
||||
<div>
|
||||
<strong>从左侧拖入能力,开始搭建流程</strong>
|
||||
<span>支持搜索 1016 项目录;单击组件也可追加。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FlowCanvas(props: { catalog: Catalog; onAddAt: (componentId: string, position: { x: number; y: number }) => void }) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<CanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import type { CatalogComponent } from "@shared/types";
|
||||
import { groupIcon } from "../components/icons";
|
||||
|
||||
export interface RpaNodeData extends Record<string, unknown> {
|
||||
component: CatalogComponent;
|
||||
label?: string;
|
||||
subtitle?: string;
|
||||
outputVar?: string;
|
||||
status?: "idle" | "running" | "succeeded" | "failed";
|
||||
}
|
||||
|
||||
export function RpaNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as RpaNodeData;
|
||||
const component = nodeData.component;
|
||||
const Icon = groupIcon(component.group);
|
||||
const pending = !component.implemented;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
"rpa-node",
|
||||
`tone-${component.group}`,
|
||||
selected ? "selected" : "",
|
||||
pending ? "pending" : "",
|
||||
nodeData.status && nodeData.status !== "idle" ? nodeData.status : ""
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="rpa-handle" />
|
||||
<span className="rpa-node-icon">
|
||||
<Icon className="icon" />
|
||||
</span>
|
||||
<span className="rpa-node-copy">
|
||||
<span className="rpa-node-top">
|
||||
<span className="rpa-node-kind">{component.kind === "native" ? "控制" : component.kind === "trigger" ? "触发" : component.group}</span>
|
||||
{pending && <span className="pending-badge">待接入</span>}
|
||||
{!pending && nodeData.outputVar && <span className="rpa-node-var">{nodeData.outputVar}</span>}
|
||||
</span>
|
||||
<span className="rpa-node-title">{nodeData.label || component.name}</span>
|
||||
<span className="rpa-node-sub">{nodeData.subtitle || component.description || component.id}</span>
|
||||
</span>
|
||||
<Handle type="source" position={Position.Right} className="rpa-handle" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
AppWindow,
|
||||
Bot,
|
||||
Boxes,
|
||||
CirclePlay,
|
||||
Database,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe2,
|
||||
History,
|
||||
Layers,
|
||||
MousePointer2,
|
||||
ScanLine,
|
||||
Send,
|
||||
Settings,
|
||||
Smartphone,
|
||||
Table2,
|
||||
Workflow,
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
control: GitBranch,
|
||||
trigger: Zap,
|
||||
app: AppWindow,
|
||||
web: Globe2,
|
||||
office: Table2,
|
||||
data: Database,
|
||||
file: FileText,
|
||||
desktop: MousePointer2,
|
||||
vision: ScanLine,
|
||||
message: Send,
|
||||
mobile: Smartphone
|
||||
};
|
||||
|
||||
export function groupIcon(group: string): LucideIcon {
|
||||
return GROUP_ICONS[group] || Boxes;
|
||||
}
|
||||
|
||||
export const railIcons = {
|
||||
editor: Workflow,
|
||||
runs: History,
|
||||
settings: Settings
|
||||
};
|
||||
|
||||
export const uiIcons = {
|
||||
play: CirclePlay,
|
||||
bot: Bot,
|
||||
layers: Layers
|
||||
};
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { RpaApi } from "../../preload/index";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
rpa: RpaApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/app.css";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Check, CirclePlay, Loader2, Redo2, Save, Undo2 } from "lucide-react";
|
||||
import type { Catalog } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { Palette } from "../panels/Palette";
|
||||
import { Properties } from "../panels/Properties";
|
||||
import { FlowCanvas } from "../canvas/FlowCanvas";
|
||||
import { containerSlots } from "../canvas/ContainerNode";
|
||||
|
||||
interface EditorPageProps {
|
||||
catalog: Catalog;
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}
|
||||
|
||||
export function EditorPage({ catalog, onToast }: EditorPageProps) {
|
||||
const flow = useEditorStore((state) => state.flow);
|
||||
const dirty = useEditorStore((state) => state.dirty);
|
||||
const addNode = useEditorStore((state) => state.addNode);
|
||||
const addChild = useEditorStore((state) => state.addChild);
|
||||
const undo = useEditorStore((state) => state.undo);
|
||||
const redo = useEditorStore((state) => state.redo);
|
||||
const renameFlow = useEditorStore((state) => state.renameFlow);
|
||||
const markSaved = useEditorStore((state) => state.markSaved);
|
||||
const canUndo = useEditorStore((state) => state.past.length > 0);
|
||||
const canRedo = useEditorStore((state) => state.future.length > 0);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [savedAt, setSavedAt] = useState<string | null>(null);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
const current = useEditorStore.getState().flow;
|
||||
if (!current || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await window.rpa.flows.save(current);
|
||||
markSaved();
|
||||
setSavedAt(new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }));
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [markSaved, onToast, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const timer = window.setTimeout(() => void save(), 900);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [dirty, flow, save]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: KeyboardEvent) => {
|
||||
const mod = event.ctrlKey || event.metaKey;
|
||||
if (!mod) return;
|
||||
if (event.key.toLowerCase() === "s") {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
} else if (event.key.toLowerCase() === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
undo();
|
||||
} else if ((event.key.toLowerCase() === "y" || (event.key.toLowerCase() === "z" && event.shiftKey))) {
|
||||
event.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [redo, save, undo]);
|
||||
|
||||
if (!flow) {
|
||||
return (
|
||||
<div className="loading">
|
||||
<Loader2 className="icon spin" />
|
||||
正在加载流程…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const addAtEnd = (componentId: string) => {
|
||||
const state = useEditorStore.getState();
|
||||
const nodes = state.flow?.nodes || [];
|
||||
const selected = nodes.find((node) => node.id === state.selectedNodeId);
|
||||
if (selected?.parentId) {
|
||||
addChild(selected.parentId, selected.slot || "body", componentId);
|
||||
return;
|
||||
}
|
||||
const selectedComponent = selected ? catalog.components.find((item) => item.id === selected.componentId) : null;
|
||||
if (selected && selectedComponent && selectedComponent.slots.length > 0) {
|
||||
addChild(selected.id, containerSlots(selectedComponent)[0].key, componentId);
|
||||
return;
|
||||
}
|
||||
const maxY = nodes.reduce((max, node) => Math.max(max, node.position.y), 80);
|
||||
addNode(componentId, { x: 180, y: nodes.length <= 1 ? 240 : maxY + 116 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<Palette catalog={catalog} onAdd={addAtEnd} />
|
||||
|
||||
<section className="panel canvas-panel" aria-label="流程画布">
|
||||
<div className="canvas-toolbar">
|
||||
<div className="flow-meta">
|
||||
<input
|
||||
className="flow-name"
|
||||
value={flow.name}
|
||||
onChange={(event) => renameFlow(event.target.value)}
|
||||
aria-label="流程名称"
|
||||
/>
|
||||
<div className="flow-sub">
|
||||
<span>{flow.nodes.length} 个节点</span>
|
||||
<span className="dot" />
|
||||
<span>{flow.edges.length} 条连线</span>
|
||||
<span className="dot" />
|
||||
{saving ? (
|
||||
<span>保存中…</span>
|
||||
) : dirty ? (
|
||||
<span className="dirty">有未保存修改</span>
|
||||
) : (
|
||||
<span>{savedAt ? `已保存 ${savedAt}` : "已保存"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar-actions">
|
||||
<button type="button" className="icon-btn" title="撤销 (Ctrl+Z)" onClick={undo} disabled={!canUndo}>
|
||||
<Undo2 className="icon" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" title="重做 (Ctrl+Y)" onClick={redo} disabled={!canRedo}>
|
||||
<Redo2 className="icon" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" title="保存 (Ctrl+S)" onClick={() => void save()} disabled={saving}>
|
||||
{saving ? <Loader2 className="icon spin" /> : <Save className="icon" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled
|
||||
title="Day 2 接入执行内核后开放"
|
||||
>
|
||||
<CirclePlay className="icon" /> 运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FlowCanvas catalog={catalog} onAddAt={(componentId, position) => addNode(componentId, position)} />
|
||||
|
||||
<div className="canvas-badge">
|
||||
<Check className="icon" />
|
||||
可执行 {catalog.components.filter((item) => item.implemented).length} / {catalog.counts.total} 项
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Properties catalog={catalog} onToast={onToast} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { History, Play, RotateCcw } from "lucide-react";
|
||||
|
||||
export function RunsPage({ onBack }: { onBack: () => void }) {
|
||||
return (
|
||||
<div className="runs">
|
||||
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div className="panel-head">
|
||||
<History className="icon" style={{ width: 15, height: 15 }} />
|
||||
<strong>运行记录</strong>
|
||||
<span className="spacer" />
|
||||
<span className="count-pill">0</span>
|
||||
</div>
|
||||
<div className="run-list">
|
||||
<div className="empty">
|
||||
<History className="icon" />
|
||||
<strong>还没有运行记录</strong>
|
||||
<span>执行引擎 Day 2 接入后,每次运行的状态、耗时、日志和截图会出现在这里。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel run-detail">
|
||||
<div className="panel-head">
|
||||
<Play className="icon" style={{ width: 15, height: 15 }} />
|
||||
<strong>运行详情</strong>
|
||||
</div>
|
||||
<div className="run-detail-body">
|
||||
<div className="empty" style={{ minHeight: 360 }}>
|
||||
<Play className="icon" />
|
||||
<strong>未选择运行</strong>
|
||||
<span>左侧选择一条运行记录后,这里显示每个节点的状态、变量与证据。</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<div className="panel-head">
|
||||
<strong>快捷操作</strong>
|
||||
</div>
|
||||
<div style={{ padding: 14, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<button type="button" className="btn" onClick={onBack}>
|
||||
<RotateCcw className="icon" /> 返回流程编排
|
||||
</button>
|
||||
<div className="field-hint" style={{ lineHeight: "17px" }}>
|
||||
运行引擎完成后,这里会显示最近成功率、平均耗时和失败节点 TOP。
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Cpu, Database, FolderCog, HardDrive, Info } from "lucide-react";
|
||||
import type { AppInfo, Catalog } from "@shared/types";
|
||||
|
||||
export function SettingsPage({ catalog }: { catalog: Catalog }) {
|
||||
const [info, setInfo] = useState<AppInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
|
||||
}, []);
|
||||
|
||||
const executable = catalog.components.filter((item) => item.implemented).length;
|
||||
|
||||
return (
|
||||
<div className="settings">
|
||||
<aside className="panel setting-nav">
|
||||
<button type="button" className="active">
|
||||
关于与运行环境
|
||||
</button>
|
||||
<button type="button">组件目录</button>
|
||||
<button type="button">执行与证据</button>
|
||||
<button type="button">浏览器白名单</button>
|
||||
</aside>
|
||||
|
||||
<section className="panel setting-body">
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Info className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
版本与运行环境
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>应用版本</dt>
|
||||
<dd>{info?.version || "0.1.0"}</dd>
|
||||
<dt>Electron / Chromium</dt>
|
||||
<dd>
|
||||
{info?.electron || "-"} / {info?.chrome || "-"}
|
||||
</dd>
|
||||
<dt>Node</dt>
|
||||
<dd>{info?.node || "-"}</dd>
|
||||
<dt>运行模式</dt>
|
||||
<dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd>
|
||||
<dt>数据目录</dt>
|
||||
<dd>{info?.userData || "-"}</dd>
|
||||
<dt>目录文件</dt>
|
||||
<dd>{info?.catalogPath || "-"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Database className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
组件目录
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>目录总数</dt>
|
||||
<dd>{catalog.counts.total} 项</dd>
|
||||
<dt>厂商组件 / 控制 / 触发</dt>
|
||||
<dd>
|
||||
{catalog.counts.vendor} / {catalog.counts.native} / {catalog.counts.triggers}
|
||||
</dd>
|
||||
<dt>字段总数</dt>
|
||||
<dd>{catalog.counts.fields}</dd>
|
||||
<dt>已接入执行器</dt>
|
||||
<dd>
|
||||
{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)
|
||||
</dd>
|
||||
<dt>待接入</dt>
|
||||
<dd>{catalog.counts.total - executable} 项(灰标展示)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<Cpu className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
执行方式
|
||||
</h3>
|
||||
<dl className="kv">
|
||||
<dt>执行位置</dt>
|
||||
<dd>本机主进程(无云端依赖)</dd>
|
||||
<dt>浏览器</dt>
|
||||
<dd>内嵌 WebContentsView + CDP(Day 2)</dd>
|
||||
<dt>桌面</dt>
|
||||
<dd>PowerShell 桥:UIA + SendInput(Day 2)</dd>
|
||||
<dt>Office</dt>
|
||||
<dd>exceljs / COM(Day 3)</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<HardDrive className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
数据与证据
|
||||
</h3>
|
||||
<p className="field-hint" style={{ lineHeight: "18px" }}>
|
||||
流程以 JSON 原子写入数据目录;运行记录与截图将写入 runs/<runId>/。运行期不加载 CDN、字体或云服务。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="setting-card">
|
||||
<h3>
|
||||
<FolderCog className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
|
||||
参考与边界
|
||||
</h3>
|
||||
<p className="field-hint" style={{ lineHeight: "18px" }}>
|
||||
组件元数据来自项目内 1016 项目录(997 厂商 + 16 控制 + 3 触发)。厂商代码、二进制与模型不随产品分发;未接入执行器的组件
|
||||
以灰标展示,运行到该节点会明确报错。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronDown, Search, Sparkles } from "lucide-react";
|
||||
import type { Catalog, CatalogComponent } from "@shared/types";
|
||||
import { groupIcon } from "../components/icons";
|
||||
|
||||
type StatusFilter = "all" | "executable" | "pending";
|
||||
|
||||
const ITEM_HEIGHT = 58;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
interface PaletteProps {
|
||||
catalog: Catalog;
|
||||
onAdd: (componentId: string) => void;
|
||||
}
|
||||
|
||||
export function Palette({ catalog, onAdd }: PaletteProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [group, setGroup] = useState<string>("all");
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
const [viewport, setViewport] = useState(520);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const executableCount = useMemo(
|
||||
() => catalog.components.filter((item) => item.implemented).length,
|
||||
[catalog]
|
||||
);
|
||||
const pendingCount = catalog.components.length - executableCount;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const keyword = query.trim().toLowerCase();
|
||||
return catalog.components.filter((component) => {
|
||||
if (group !== "all" && component.group !== group) return false;
|
||||
if (status === "executable" && !component.implemented) return false;
|
||||
if (status === "pending" && component.implemented) return false;
|
||||
if (!keyword) return true;
|
||||
const haystack = [
|
||||
component.name,
|
||||
component.id,
|
||||
component.functionName,
|
||||
component.description,
|
||||
...(component.tags || [])
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
});
|
||||
}, [catalog.components, group, query, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const next = entries[0]?.contentRect.height;
|
||||
if (next) setViewport(next);
|
||||
});
|
||||
observer.observe(listRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setScrollTop(0);
|
||||
if (listRef.current) listRef.current.scrollTop = 0;
|
||||
}, [query, status, group]);
|
||||
|
||||
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - OVERSCAN);
|
||||
const end = Math.min(filtered.length, Math.ceil((scrollTop + viewport) / ITEM_HEIGHT) + OVERSCAN);
|
||||
const rows = filtered.slice(start, end);
|
||||
|
||||
return (
|
||||
<aside className="panel palette" aria-label="组件库">
|
||||
<div className="palette-head">
|
||||
<div className="palette-title-row">
|
||||
<span className="palette-title">组件库</span>
|
||||
<span className="palette-count">
|
||||
{filtered.length} / {catalog.counts.total}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="search-box">
|
||||
<Search className="icon" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索名称 / ID / 标签"
|
||||
aria-label="搜索组件"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="seg" role="tablist" aria-label="组件状态筛选">
|
||||
<button type="button" className={status === "all" ? "active" : ""} onClick={() => setStatus("all")}>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={status === "executable" ? "active" : ""}
|
||||
onClick={() => setStatus("executable")}
|
||||
>
|
||||
可执行 {executableCount}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={status === "pending" ? "active" : ""}
|
||||
onClick={() => setStatus("pending")}
|
||||
>
|
||||
待接入 {pendingCount}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="group-row" role="tablist" aria-label="组件分组">
|
||||
<button
|
||||
type="button"
|
||||
className={`group-chip${group === "all" ? " active" : ""}`}
|
||||
onClick={() => setGroup("all")}
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
{catalog.groups.map((item) => {
|
||||
const Icon = groupIcon(item.id);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={item.id}
|
||||
className={`group-chip${group === item.id ? " active" : ""}`}
|
||||
onClick={() => setGroup(item.id)}
|
||||
title={`${item.label}(${item.count})`}
|
||||
>
|
||||
<Icon className="icon" style={{ width: 11, height: 11, display: "inline-block" }} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="palette-list"
|
||||
ref={listRef}
|
||||
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
|
||||
>
|
||||
<div className="palette-inner" style={{ height: filtered.length * ITEM_HEIGHT }}>
|
||||
{rows.map((component, index) => {
|
||||
const absolute = start + index;
|
||||
return (
|
||||
<PaletteRow
|
||||
key={component.id}
|
||||
component={component}
|
||||
top={absolute * ITEM_HEIGHT}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filtered.length === 0 && (
|
||||
<div className="empty" style={{ minHeight: 200 }}>
|
||||
<Sparkles className="icon" />
|
||||
<strong>没有匹配的组件</strong>
|
||||
<span>换个关键词,或切换状态/分组筛选。</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="palette-more">
|
||||
<ChevronDown className="icon" style={{ width: 12, height: 12 }} />
|
||||
拖拽到画布,或单击追加;灰点为待接入。
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteRow({
|
||||
component,
|
||||
top,
|
||||
onAdd
|
||||
}: {
|
||||
component: CatalogComponent;
|
||||
top: number;
|
||||
onAdd: (id: string) => void;
|
||||
}) {
|
||||
const Icon = groupIcon(component.group);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
className={`palette-row${component.implemented ? "" : " pending"}`}
|
||||
data-group={component.group}
|
||||
style={{ top }}
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData("application/rpa-component", component.id);
|
||||
event.dataTransfer.effectAllowed = "copy";
|
||||
}}
|
||||
onClick={() => onAdd(component.id)}
|
||||
title={`${component.name}\n${component.id}\n${component.description || ""}`}
|
||||
>
|
||||
<span className="palette-icon">
|
||||
<Icon className="icon" />
|
||||
</span>
|
||||
<span className="palette-copy">
|
||||
<span className="palette-name">{component.name}</span>
|
||||
<span className="palette-id">{component.id}</span>
|
||||
</span>
|
||||
<span className={`status-dot${component.implemented ? "" : " pending"}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FolderOpen,
|
||||
Info,
|
||||
Play,
|
||||
Plus,
|
||||
Settings2,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Variable
|
||||
} from "lucide-react";
|
||||
import type { Catalog, CatalogField, FlowNode, FlowVariable } from "@shared/types";
|
||||
import { useEditorStore } from "../store/editorStore";
|
||||
import { groupIcon } from "../components/icons";
|
||||
import { containerSlots } from "../canvas/ContainerNode";
|
||||
|
||||
type Tab = "props" | "vars" | "run";
|
||||
|
||||
interface PropertiesProps {
|
||||
catalog: Catalog;
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}
|
||||
|
||||
function textOf(value: unknown): string {
|
||||
if (value === undefined || value === null) return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function boolOf(value: unknown): boolean {
|
||||
return value === true || value === "1" || value === "true" || value === "开启";
|
||||
}
|
||||
|
||||
function numberOr(value: unknown, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function Properties({ catalog, onToast }: PropertiesProps) {
|
||||
const [tab, setTab] = useState<Tab>("props");
|
||||
const flow = useEditorStore((state) => state.flow);
|
||||
const selectedNodeId = useEditorStore((state) => state.selectedNodeId);
|
||||
const updateNode = useEditorStore((state) => state.updateNode);
|
||||
const updateValue = useEditorStore((state) => state.updateValue);
|
||||
const updateOption = useEditorStore((state) => state.updateOption);
|
||||
const duplicateNode = useEditorStore((state) => state.duplicateNode);
|
||||
const removeNode = useEditorStore((state) => state.removeNode);
|
||||
const addVariable = useEditorStore((state) => state.addVariable);
|
||||
const removeVariable = useEditorStore((state) => state.removeVariable);
|
||||
|
||||
const node = useMemo(
|
||||
() => flow?.nodes.find((item) => item.id === selectedNodeId) || null,
|
||||
[flow, selectedNodeId]
|
||||
);
|
||||
const component = useMemo(
|
||||
() => (node ? catalog.components.find((item) => item.id === node.componentId) || null : null),
|
||||
[catalog, node]
|
||||
);
|
||||
const parent = useMemo(
|
||||
() => (node?.parentId ? flow?.nodes.find((item) => item.id === node.parentId) || null : null),
|
||||
[flow, node]
|
||||
);
|
||||
const childNodes = useMemo(
|
||||
() => (node ? flow?.nodes.filter((item) => item.parentId === node.id) || [] : []),
|
||||
[flow, node]
|
||||
);
|
||||
|
||||
if (!flow) return null;
|
||||
|
||||
return (
|
||||
<aside className="panel props" aria-label="属性面板">
|
||||
{node && component ? (
|
||||
<>
|
||||
<div className="props-head">
|
||||
<span className={`props-icon tone-${component.group}`}>
|
||||
{(() => {
|
||||
const Icon = groupIcon(component.group);
|
||||
return <Icon className="icon" />;
|
||||
})()}
|
||||
</span>
|
||||
<div className="props-title-wrap">
|
||||
<span className="props-kicker">
|
||||
{parent
|
||||
? `位于 ${parent.label || parent.componentId}`
|
||||
: component.kind === "native"
|
||||
? "控制节点"
|
||||
: component.kind === "trigger"
|
||||
? "触发器"
|
||||
: "组件"}
|
||||
</span>
|
||||
<strong className="props-title">{node.label || component.name}</strong>
|
||||
<span className="props-id">{component.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tabs" role="tablist">
|
||||
<button type="button" className={tab === "props" ? "active" : ""} onClick={() => setTab("props")}>
|
||||
属性
|
||||
</button>
|
||||
<button type="button" className={tab === "vars" ? "active" : ""} onClick={() => setTab("vars")}>
|
||||
变量
|
||||
</button>
|
||||
<button type="button" className={tab === "run" ? "active" : ""} onClick={() => setTab("run")}>
|
||||
运行
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="props-body">
|
||||
{tab === "props" && (
|
||||
<PropertyTab
|
||||
node={node}
|
||||
component={component}
|
||||
parent={parent}
|
||||
childNodes={childNodes}
|
||||
onLabel={(label) => updateNode(node.id, { label })}
|
||||
onChange={(key, value) => updateValue(node.id, key, value)}
|
||||
onChangeOption={(id, key, value) =>
|
||||
updateOption(id, key, value as string | number | boolean | undefined)
|
||||
}
|
||||
onDetach={() => updateNode(node.id, { parentId: null, slot: undefined })}
|
||||
onToast={onToast}
|
||||
/>
|
||||
)}
|
||||
{tab === "vars" && (
|
||||
<VariablesTab
|
||||
flowVariables={flow.variables}
|
||||
nodes={flow.nodes}
|
||||
onAdd={addVariable}
|
||||
onRemove={removeVariable}
|
||||
/>
|
||||
)}
|
||||
{tab === "run" && <RunTab node={node} component={component} />}
|
||||
</div>
|
||||
|
||||
<div className="props-foot">
|
||||
<span>{component.implemented ? "已接入执行器" : "待接入执行器"}</span>
|
||||
<div style={{ display: "flex", gap: 4 }}>
|
||||
<button type="button" className="icon-btn" title="复制节点" onClick={() => duplicateNode(node.id)}>
|
||||
<Copy className="icon" />
|
||||
</button>
|
||||
<button type="button" className="icon-btn" title="删除节点" onClick={() => removeNode(node.id)}>
|
||||
<Trash2 className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="props-head">
|
||||
<span className="props-icon">
|
||||
<Settings2 className="icon" />
|
||||
</span>
|
||||
<div className="props-title-wrap">
|
||||
<span className="props-kicker">未选择节点</span>
|
||||
<strong className="props-title">节点属性</strong>
|
||||
<span className="props-id">从画布选择一个节点</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="prop-empty">
|
||||
<SlidersHorizontal className="icon" />
|
||||
<strong>未选择节点</strong>
|
||||
<span>单击画布节点查看并编辑参数、失败策略与输出变量。</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyTab({
|
||||
node,
|
||||
component,
|
||||
parent,
|
||||
childNodes,
|
||||
onLabel,
|
||||
onChange,
|
||||
onChangeOption,
|
||||
onDetach,
|
||||
onToast
|
||||
}: {
|
||||
node: FlowNode;
|
||||
component: Catalog["components"][number];
|
||||
parent: FlowNode | null;
|
||||
childNodes: FlowNode[];
|
||||
onLabel: (label: string) => void;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
onChangeOption: (id: string, key: keyof FlowNode["options"], value: unknown) => void;
|
||||
onDetach: () => void;
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}) {
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const basic = component.fields.filter((field) => !field.advanced && field.widget !== "hidden");
|
||||
const advanced = component.fields.filter((field) => field.advanced && field.widget !== "hidden");
|
||||
const slots = component.slots.length > 0 ? containerSlots(component) : [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{!component.implemented && (
|
||||
<div className="notice warn">
|
||||
<AlertTriangle className="icon" />
|
||||
<div>
|
||||
<strong>当前版本无执行器</strong>
|
||||
<span>可保存与连线;运行到该节点会停止并给出替代建议。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parent && (
|
||||
<div className="notice">
|
||||
<Boxes className="icon" />
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong>位于容器「{parent.label || parent.componentId}」</strong>
|
||||
<span>{node.slot ? `槽位:${node.slot}` : "未指定槽位"}</span>
|
||||
</div>
|
||||
<button type="button" className="mini-btn" onClick={onDetach}>
|
||||
移出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{slots.length > 0 && (
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Boxes className="icon" /> 容器内容
|
||||
</div>
|
||||
{slots.map((slot) => {
|
||||
const count = childNodes.filter((child) => child.slot === slot.key).length;
|
||||
return (
|
||||
<div className="slot-summary" key={slot.key}>
|
||||
<span>{slot.label}</span>
|
||||
<strong>{count} 个步骤</strong>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="field-hint">把组件拖到画布上的容器分支,或选中容器后点击左侧组件追加。</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Info className="icon" /> 基本
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">节点名称</label>
|
||||
<input
|
||||
className="text-input"
|
||||
value={node.label ?? ""}
|
||||
placeholder={component.name}
|
||||
onChange={(event) => onLabel(event.target.value)}
|
||||
maxLength={60}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">组件</label>
|
||||
<div className="readonly-line">
|
||||
<span>{component.name}</span>
|
||||
<code>{component.id}</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<SlidersHorizontal className="icon" /> 输入参数
|
||||
</div>
|
||||
{basic.length === 0 && <div className="field-hint">该组件没有普通输入字段。</div>}
|
||||
{basic.map((field) => (
|
||||
<FieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
node={node}
|
||||
onChange={onChange}
|
||||
onToast={onToast}
|
||||
/>
|
||||
))}
|
||||
{advanced.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`advanced-toggle${advancedOpen ? " open" : ""}`}
|
||||
onClick={() => setAdvancedOpen((value) => !value)}
|
||||
>
|
||||
<ChevronRight className="icon" />
|
||||
高级参数({advanced.length})
|
||||
</button>
|
||||
{advancedOpen &&
|
||||
advanced.map((field) => (
|
||||
<FieldControl
|
||||
key={field.key}
|
||||
field={field}
|
||||
node={node}
|
||||
onChange={onChange}
|
||||
onToast={onToast}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Variable className="icon" /> 输出
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">输出到变量</label>
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(node.options.outputVar)}
|
||||
placeholder={component.output.suggestedName || "可选,例如 result"}
|
||||
onChange={(event) => onChangeOption(node.id, "outputVar", event.target.value)}
|
||||
/>
|
||||
<span className="field-hint">运行结果写入该变量;下游节点用 {"${变量名}"} 引用。</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Settings2 className="icon" /> 失败策略
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<NumberField
|
||||
label="超时(ms)"
|
||||
value={numberOr(node.options.timeoutMs, 30000)}
|
||||
min={0}
|
||||
max={604800000}
|
||||
onChange={(value) => onChangeOption(node.id, "timeoutMs", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="重试次数"
|
||||
value={numberOr(node.options.retryCount, 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={(value) => onChangeOption(node.id, "retryCount", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="重试间隔(ms)"
|
||||
value={numberOr(node.options.retryIntervalMs, 1000)}
|
||||
min={1000}
|
||||
max={600000}
|
||||
onChange={(value) => onChangeOption(node.id, "retryIntervalMs", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="前置延时(ms)"
|
||||
value={numberOr(node.options.delayBeforeMs, 0)}
|
||||
min={0}
|
||||
max={100000000}
|
||||
onChange={(value) => onChangeOption(node.id, "delayBeforeMs", value)}
|
||||
/>
|
||||
<NumberField
|
||||
label="后置延时(ms)"
|
||||
value={numberOr(node.options.delayAfterMs, 0)}
|
||||
min={0}
|
||||
max={100000000}
|
||||
onChange={(value) => onChangeOption(node.id, "delayAfterMs", value)}
|
||||
/>
|
||||
</div>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(node.options.skipOnError)}
|
||||
onChange={(event) => onChangeOption(node.id, "skipOnError", event.target.checked)}
|
||||
/>
|
||||
失败后跳过并继续
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={node.options.logException !== false}
|
||||
onChange={(event) => onChangeOption(node.id, "logException", event.target.checked)}
|
||||
/>
|
||||
记录异常堆栈
|
||||
</label>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldControl({
|
||||
field,
|
||||
node,
|
||||
onChange,
|
||||
onToast
|
||||
}: {
|
||||
field: CatalogField;
|
||||
node: FlowNode;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
onToast: (message: string, kind?: "ok" | "error") => void;
|
||||
}) {
|
||||
const raw = node.values[field.key] ?? field.defaultValue;
|
||||
|
||||
const pickPath = async (directories: boolean) => {
|
||||
try {
|
||||
const selected = await window.rpa.dialog.open({
|
||||
title: field.label,
|
||||
directories,
|
||||
filters: directories
|
||||
? undefined
|
||||
: [
|
||||
{ name: "Excel", extensions: ["xlsx", "xlsm", "xls"] },
|
||||
{ name: "全部文件", extensions: ["*"] }
|
||||
]
|
||||
});
|
||||
if (selected) onChange(field.key, selected);
|
||||
} catch (error) {
|
||||
onToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
};
|
||||
|
||||
if (field.widget === "boolean") {
|
||||
return (
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={boolOf(raw)} onChange={(event) => onChange(field.key, event.target.checked)} />
|
||||
{field.label}
|
||||
{field.required && <span className="required">*</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="field">
|
||||
<label className="field-label">
|
||||
{field.label}
|
||||
{field.required && <span className="required">*</span>}
|
||||
{field.unit && <span className="field-hint">({field.unit})</span>}
|
||||
</label>
|
||||
|
||||
{field.widget === "textarea" || field.widget === "code" ? (
|
||||
<textarea
|
||||
className="textarea-input"
|
||||
value={textOf(raw)}
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
rows={field.widget === "code" ? 5 : 3}
|
||||
/>
|
||||
) : field.widget === "select" && field.options?.length ? (
|
||||
<select className="select-input" value={textOf(raw)} onChange={(event) => onChange(field.key, event.target.value)}>
|
||||
<option value="">请选择</option>
|
||||
{field.options.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.widget === "radio" && field.options?.length ? (
|
||||
<div className="chip-row">
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
type="button"
|
||||
key={option}
|
||||
className={`option-chip${textOf(raw) === option ? " active" : ""}`}
|
||||
onClick={() => onChange(field.key, option)}
|
||||
>
|
||||
{option}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : field.widget === "file" || field.widget === "folder" ? (
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(raw)}
|
||||
placeholder={field.placeholder || (field.widget === "folder" ? "选择目录" : "选择文件")}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
<button type="button" className="mini-btn" onClick={() => void pickPath(field.widget === "folder")} title="浏览">
|
||||
<FolderOpen className="icon" style={{ width: 13, height: 13 }} />
|
||||
</button>
|
||||
</div>
|
||||
) : field.widget === "element" ? (
|
||||
<div className="field-row">
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(raw)}
|
||||
placeholder="css:#submit / aria:button"
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
<button type="button" className="mini-btn" disabled title="Day 2 接入浏览器拾取器">
|
||||
拾取
|
||||
</button>
|
||||
</div>
|
||||
) : field.widget === "secret" ? (
|
||||
<input
|
||||
className="text-input"
|
||||
type="password"
|
||||
value={textOf(raw)}
|
||||
placeholder={field.placeholder || "密钥引用"}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
) : field.widget === "number" ? (
|
||||
<input
|
||||
className="text-input"
|
||||
type="number"
|
||||
value={textOf(raw)}
|
||||
placeholder={field.placeholder || ""}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
className="text-input"
|
||||
value={textOf(raw)}
|
||||
placeholder={field.placeholder || (field.widget === "region" ? "区域 / 坐标 JSON" : "")}
|
||||
onChange={(event) => onChange(field.key, event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.description && <span className="field-hint">{field.description}</span>}
|
||||
{field.valueTypes?.length > 0 && (
|
||||
<span className="field-hint mono">{field.valueTypes.join(" · ")}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
onChange
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<label className="field compact">
|
||||
<span className="field-label">{label}</span>
|
||||
<input
|
||||
className="text-input"
|
||||
type="number"
|
||||
value={value}
|
||||
min={min}
|
||||
max={max}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function VariablesTab({
|
||||
flowVariables,
|
||||
nodes,
|
||||
onAdd,
|
||||
onRemove
|
||||
}: {
|
||||
flowVariables: FlowVariable[];
|
||||
nodes: FlowNode[];
|
||||
onAdd: (variable: FlowVariable) => void;
|
||||
onRemove: (name: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("string");
|
||||
const [defaultValue, setDefaultValue] = useState("");
|
||||
const outputs = nodes.filter((node) => node.options.outputVar);
|
||||
|
||||
const submit = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!/^[A-Za-z_\u4e00-\u9fa5][\w\u4e00-\u9fa5]*$/.test(trimmed)) return;
|
||||
onAdd({ name: trimmed, type, defaultValue, scope: "flow" });
|
||||
setName("");
|
||||
setDefaultValue("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Variable className="icon" /> 流程变量
|
||||
</div>
|
||||
{flowVariables.length === 0 && <div className="field-hint">还没有流程变量。可在下方新增,或用节点输出变量。</div>}
|
||||
{flowVariables.map((variable) => (
|
||||
<div className="var-row" key={variable.name}>
|
||||
<span className="var-name">{variable.name}</span>
|
||||
<span className="var-type">{variable.type}</span>
|
||||
<button type="button" className="icon-btn" title="删除变量" onClick={() => onRemove(variable.name)}>
|
||||
<Trash2 className="icon" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="field-grid">
|
||||
<label className="field compact">
|
||||
<span className="field-label">名称</span>
|
||||
<input className="text-input" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如 orderNo" />
|
||||
</label>
|
||||
<label className="field compact">
|
||||
<span className="field-label">类型</span>
|
||||
<select className="select-input" value={type} onChange={(event) => setType(event.target.value)}>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="boolean">boolean</option>
|
||||
<option value="list">list</option>
|
||||
<option value="dict">dict</option>
|
||||
<option value="excel">excel</option>
|
||||
<option value="browser">browser</option>
|
||||
<option value="any">any</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="field-label">默认值</span>
|
||||
<input className="text-input" value={defaultValue} onChange={(event) => setDefaultValue(event.target.value)} />
|
||||
</div>
|
||||
<button type="button" className="btn" onClick={submit} disabled={!name.trim()}>
|
||||
<Plus className="icon" /> 新增变量
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Info className="icon" /> 节点输出
|
||||
</div>
|
||||
{outputs.length === 0 && <div className="field-hint">还没有节点配置输出变量。</div>}
|
||||
{outputs.map((node) => (
|
||||
<div className="var-row" key={node.id}>
|
||||
<span className="var-name">{textOf(node.options.outputVar)}</span>
|
||||
<span className="var-type">{node.componentId}</span>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTab({ node, component }: { node: FlowNode; component: Catalog["components"][number] }) {
|
||||
return (
|
||||
<>
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Play className="icon" /> 运行状态
|
||||
</div>
|
||||
<div className="run-facts">
|
||||
<div>
|
||||
<span>状态</span>
|
||||
<strong>未运行</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>执行器</span>
|
||||
<strong>{component.implemented ? "已接入" : "待接入"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>超时</span>
|
||||
<strong>{numberOr(node.options.timeoutMs, 30000)} ms</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>重试</span>
|
||||
<strong>{numberOr(node.options.retryCount, 0)} 次</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn" disabled title="Day 2 接入执行内核">
|
||||
<Play className="icon" /> 单步运行(Day 2)
|
||||
</button>
|
||||
</section>
|
||||
<section className="prop-section">
|
||||
<div className="section-title">
|
||||
<Info className="icon" /> 说明
|
||||
</div>
|
||||
<div className="field-hint" style={{ lineHeight: "17px" }}>
|
||||
运行时会按顺序执行节点,记录状态、耗时、输出变量与截图证据。待接入节点会在此处显示失败原因与建议。
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { create } from "zustand";
|
||||
import type {
|
||||
Catalog,
|
||||
CatalogComponent,
|
||||
FlowDefinition,
|
||||
FlowEdge,
|
||||
FlowNode,
|
||||
FlowNodeOptions,
|
||||
FlowVariable
|
||||
} from "@shared/types";
|
||||
|
||||
const clone = <T,>(value: T): T => structuredClone(value);
|
||||
|
||||
let nodeSeq = 0;
|
||||
|
||||
function nextNodeId(): string {
|
||||
nodeSeq += 1;
|
||||
return `node_${Date.now().toString(36)}_${nodeSeq}`;
|
||||
}
|
||||
|
||||
function defaultValues(component: CatalogComponent): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of component.fields) {
|
||||
if (field.widget === "hidden") continue;
|
||||
if (field.defaultValue !== "") values[field.key] = field.defaultValue;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function defaultOptions(): FlowNodeOptions {
|
||||
return {
|
||||
timeoutMs: 30000,
|
||||
retryCount: 0,
|
||||
retryIntervalMs: 1000,
|
||||
skipOnError: false,
|
||||
logException: true
|
||||
};
|
||||
}
|
||||
|
||||
interface EditorState {
|
||||
flow: FlowDefinition | null;
|
||||
catalog: Catalog | null;
|
||||
selectedNodeId: string | null;
|
||||
dirty: boolean;
|
||||
past: FlowDefinition[];
|
||||
future: FlowDefinition[];
|
||||
setCatalog: (catalog: Catalog) => void;
|
||||
loadFlow: (flow: FlowDefinition) => void;
|
||||
selectNode: (id: string | null) => void;
|
||||
addNode: (componentId: string, position: { x: number; y: number }) => string | null;
|
||||
addChild: (parentId: string, slot: string, componentId: string) => string | null;
|
||||
moveChild: (childId: string, direction: -1 | 1) => void;
|
||||
updateNode: (id: string, patch: Partial<FlowNode>) => void;
|
||||
updateValue: (id: string, key: string, value: unknown) => void;
|
||||
updateOption: <K extends keyof FlowNodeOptions>(id: string, key: K, value: FlowNodeOptions[K]) => void;
|
||||
moveNode: (id: string, position: { x: number; y: number }) => void;
|
||||
removeNode: (id: string) => void;
|
||||
duplicateNode: (id: string) => void;
|
||||
addEdge: (edge: { source: string; target: string; sourceHandle?: string | null; targetHandle?: string | null }) => void;
|
||||
removeEdge: (id: string) => void;
|
||||
renameFlow: (name: string) => void;
|
||||
addVariable: (variable: FlowVariable) => void;
|
||||
removeVariable: (name: string) => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
markSaved: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useEditorStore = create<EditorState>((set, get) => {
|
||||
const commit = (mutate: (draft: FlowDefinition) => void) => {
|
||||
const current = get().flow;
|
||||
if (!current) return;
|
||||
const draft = clone(current);
|
||||
mutate(draft);
|
||||
draft.meta.updatedAt = new Date().toISOString();
|
||||
set({
|
||||
flow: draft,
|
||||
past: [...get().past.slice(-49), current],
|
||||
future: [],
|
||||
dirty: true
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
flow: null,
|
||||
catalog: null,
|
||||
selectedNodeId: null,
|
||||
dirty: false,
|
||||
past: [],
|
||||
future: [],
|
||||
|
||||
setCatalog: (catalog) => set({ catalog }),
|
||||
|
||||
loadFlow: (flow) => set({ flow, selectedNodeId: null, dirty: false, past: [], future: [] }),
|
||||
|
||||
selectNode: (id) => set({ selectedNodeId: id }),
|
||||
|
||||
addNode: (componentId, position) => {
|
||||
const state = get();
|
||||
const component = state.catalog?.components.find((item) => item.id === componentId);
|
||||
if (!component || !state.flow) return null;
|
||||
const id = nextNodeId();
|
||||
const node: FlowNode = {
|
||||
id,
|
||||
componentId,
|
||||
position,
|
||||
values: defaultValues(component),
|
||||
options: defaultOptions(),
|
||||
enabled: true
|
||||
};
|
||||
commit((draft) => {
|
||||
draft.nodes.push(node);
|
||||
});
|
||||
set({ selectedNodeId: id });
|
||||
return id;
|
||||
},
|
||||
|
||||
updateNode: (id, patch) => {
|
||||
commit((draft) => {
|
||||
const node = draft.nodes.find((item) => item.id === id);
|
||||
if (!node) return;
|
||||
Object.assign(node, patch);
|
||||
});
|
||||
},
|
||||
|
||||
addChild: (parentId, slot, componentId) => {
|
||||
const state = get();
|
||||
const component = state.catalog?.components.find((item) => item.id === componentId);
|
||||
const parent = state.flow?.nodes.find((item) => item.id === parentId);
|
||||
if (!component || !parent || !state.flow) return null;
|
||||
const siblings = state.flow.nodes.filter((item) => item.parentId === parentId && item.slot === slot);
|
||||
const id = nextNodeId();
|
||||
const node: FlowNode = {
|
||||
id,
|
||||
componentId,
|
||||
parentId,
|
||||
slot,
|
||||
position: { x: 0, y: siblings.length * 52 },
|
||||
values: defaultValues(component),
|
||||
options: defaultOptions(),
|
||||
enabled: true
|
||||
};
|
||||
commit((draft) => {
|
||||
const lastSibling = [...draft.nodes]
|
||||
.reverse()
|
||||
.find((item) => item.parentId === parentId && item.slot === slot);
|
||||
const index = lastSibling ? draft.nodes.indexOf(lastSibling) + 1 : draft.nodes.indexOf(parent) + 1;
|
||||
draft.nodes.splice(Math.max(0, index), 0, node);
|
||||
});
|
||||
set({ selectedNodeId: id });
|
||||
return id;
|
||||
},
|
||||
|
||||
moveChild: (childId, direction) => {
|
||||
commit((draft) => {
|
||||
const child = draft.nodes.find((item) => item.id === childId);
|
||||
if (!child?.parentId) return;
|
||||
const siblings = draft.nodes.filter(
|
||||
(item) => item.parentId === child.parentId && item.slot === child.slot
|
||||
);
|
||||
const index = siblings.findIndex((item) => item.id === childId);
|
||||
const target = index + direction;
|
||||
if (index < 0 || target < 0 || target >= siblings.length) return;
|
||||
const a = draft.nodes.indexOf(siblings[index]);
|
||||
const b = draft.nodes.indexOf(siblings[target]);
|
||||
[draft.nodes[a], draft.nodes[b]] = [draft.nodes[b], draft.nodes[a]];
|
||||
});
|
||||
},
|
||||
|
||||
updateValue: (id, key, value) => {
|
||||
commit((draft) => {
|
||||
const node = draft.nodes.find((item) => item.id === id);
|
||||
if (!node) return;
|
||||
node.values = { ...node.values, [key]: value };
|
||||
});
|
||||
},
|
||||
|
||||
updateOption: (id, key, value) => {
|
||||
commit((draft) => {
|
||||
const node = draft.nodes.find((item) => item.id === id);
|
||||
if (!node) return;
|
||||
node.options = { ...node.options, [key]: value };
|
||||
});
|
||||
},
|
||||
|
||||
moveNode: (id, position) => {
|
||||
commit((draft) => {
|
||||
const node = draft.nodes.find((item) => item.id === id);
|
||||
if (!node) return;
|
||||
node.position = position;
|
||||
});
|
||||
},
|
||||
|
||||
removeNode: (id) => {
|
||||
commit((draft) => {
|
||||
const doomed = new Set<string>([id]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const node of draft.nodes) {
|
||||
if (node.parentId && doomed.has(node.parentId) && !doomed.has(node.id)) {
|
||||
doomed.add(node.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
draft.nodes = draft.nodes.filter((node) => !doomed.has(node.id));
|
||||
draft.edges = draft.edges.filter((edge) => !doomed.has(edge.source) && !doomed.has(edge.target));
|
||||
});
|
||||
set({ selectedNodeId: null });
|
||||
},
|
||||
|
||||
duplicateNode: (id) => {
|
||||
const state = get();
|
||||
const source = state.flow?.nodes.find((item) => item.id === id);
|
||||
if (!source || !state.flow) return;
|
||||
const copy: FlowNode = clone(source);
|
||||
copy.id = nextNodeId();
|
||||
copy.position = { x: source.position.x + 36, y: source.position.y + 36 };
|
||||
copy.label = source.label ? `${source.label} 副本` : undefined;
|
||||
commit((draft) => {
|
||||
draft.nodes.push(copy);
|
||||
});
|
||||
set({ selectedNodeId: copy.id });
|
||||
},
|
||||
|
||||
addEdge: ({ source, target, sourceHandle, targetHandle }) => {
|
||||
if (!source || !target || source === target) return;
|
||||
const state = get();
|
||||
if (!state.flow) return;
|
||||
if (state.flow.edges.some((edge) => edge.source === source && edge.target === target)) return;
|
||||
const edge: FlowEdge = {
|
||||
id: `edge_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 5)}`,
|
||||
source,
|
||||
target,
|
||||
sourceHandle: sourceHandle || undefined,
|
||||
targetHandle: targetHandle || undefined
|
||||
};
|
||||
commit((draft) => {
|
||||
draft.edges.push(edge);
|
||||
});
|
||||
},
|
||||
|
||||
removeEdge: (id) => {
|
||||
commit((draft) => {
|
||||
draft.edges = draft.edges.filter((edge) => edge.id !== id);
|
||||
});
|
||||
},
|
||||
|
||||
renameFlow: (name) => {
|
||||
commit((draft) => {
|
||||
draft.name = name;
|
||||
});
|
||||
},
|
||||
|
||||
addVariable: (variable) => {
|
||||
commit((draft) => {
|
||||
if (draft.variables.some((item) => item.name === variable.name)) return;
|
||||
draft.variables.push(variable);
|
||||
});
|
||||
},
|
||||
|
||||
removeVariable: (name) => {
|
||||
commit((draft) => {
|
||||
draft.variables = draft.variables.filter((item) => item.name !== name);
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const { past, flow, future } = get();
|
||||
if (!past.length || !flow) return;
|
||||
const previous = past[past.length - 1];
|
||||
set({
|
||||
flow: previous,
|
||||
past: past.slice(0, -1),
|
||||
future: [flow, ...future].slice(0, 50),
|
||||
dirty: true
|
||||
});
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const { future, flow, past } = get();
|
||||
if (!future.length || !flow) return;
|
||||
const next = future[0];
|
||||
set({
|
||||
flow: next,
|
||||
future: future.slice(1),
|
||||
past: [...past, flow].slice(-50),
|
||||
dirty: true
|
||||
});
|
||||
},
|
||||
|
||||
markSaved: () => set({ dirty: false }),
|
||||
|
||||
reset: () => set({ flow: null, selectedNodeId: null, dirty: false, past: [], future: [] })
|
||||
};
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
--blue: #2264e5;
|
||||
--blue-dark: #155bc0;
|
||||
--blue-soft: #eaf1ff;
|
||||
--blue-mist: #f3f7ff;
|
||||
--ink: #172033;
|
||||
--ink-2: #4e5a6b;
|
||||
--muted: #788494;
|
||||
--faint: #a1acba;
|
||||
--bg: #e7edf3;
|
||||
--panel: #f2f5f8;
|
||||
--workspace: #f3f6f9;
|
||||
--surface: #ffffff;
|
||||
--soft: #f5f7fa;
|
||||
--soft-2: #edf2f7;
|
||||
--line: rgba(23, 32, 51, 0.075);
|
||||
--line-strong: rgba(23, 32, 51, 0.12);
|
||||
--success: #079455;
|
||||
--success-deep: #067647;
|
||||
--success-bg: #e9f7ee;
|
||||
--warn: #b45309;
|
||||
--warn-deep: #7a2e0e;
|
||||
--warn-bg: #fff4e5;
|
||||
--danger: #d92d20;
|
||||
--danger-deep: #912018;
|
||||
--danger-bg: #feeceb;
|
||||
--teal: #0e7b6b;
|
||||
--teal-bg: #e8f7f3;
|
||||
--violet: #6b46c1;
|
||||
--violet-bg: #f2edff;
|
||||
--amber: #b56a08;
|
||||
--amber-bg: #fff6e8;
|
||||
--radius: 20px;
|
||||
--shadow-app: 0 24px 70px rgba(25, 39, 60, 0.16);
|
||||
--shadow-panel: 0 6px 20px rgba(35, 55, 72, 0.045);
|
||||
--shadow-pop: 0 18px 44px rgba(23, 38, 58, 0.16);
|
||||
--focus: 0 0 0 4px rgba(34, 100, 229, 0.12);
|
||||
--rail-w: 76px;
|
||||
--titlebar-h: 58px;
|
||||
--font: "MiSans", "Microsoft YaHei UI", "Microsoft YaHei", "PingFang SC", "Noto Sans SC", sans-serif;
|
||||
--mono: "Cascadia Code", "Consolas", "SFMono-Regular", monospace;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
export type CatalogWidget =
|
||||
| "text"
|
||||
| "textarea"
|
||||
| "number"
|
||||
| "select"
|
||||
| "radio"
|
||||
| "boolean"
|
||||
| "file"
|
||||
| "folder"
|
||||
| "element"
|
||||
| "code"
|
||||
| "secret"
|
||||
| "region"
|
||||
| "hidden";
|
||||
|
||||
export interface CatalogField {
|
||||
key: string;
|
||||
label: string;
|
||||
widget: CatalogWidget;
|
||||
type: "string" | "number" | "boolean" | "object";
|
||||
displayType: string;
|
||||
required: boolean;
|
||||
defaultValue: string;
|
||||
placeholder: string;
|
||||
description: string;
|
||||
advanced: boolean;
|
||||
valueTypes: string[];
|
||||
options?: string[];
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface CatalogOutput {
|
||||
label: string;
|
||||
key: string;
|
||||
suggestedName: string;
|
||||
valueTypes: string[];
|
||||
fields: Array<{ key: string; label: string; defaultValue: string; valueTypes: string[] }>;
|
||||
}
|
||||
|
||||
export interface CatalogComponent {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "vendor" | "native" | "trigger";
|
||||
group: string;
|
||||
categories: string[];
|
||||
categorySignature: string;
|
||||
description: string;
|
||||
messageTemplate: string;
|
||||
baseType: string;
|
||||
functionName: string;
|
||||
functionCalls: Array<{ function: string; args: string[]; assignTo: string }>;
|
||||
version: string;
|
||||
tags: string[];
|
||||
platformTag: string | null;
|
||||
icon: string;
|
||||
risk: "low" | "high";
|
||||
fields: CatalogField[];
|
||||
output: CatalogOutput;
|
||||
slots: string[];
|
||||
nodeKind?: string;
|
||||
implemented?: boolean;
|
||||
}
|
||||
|
||||
export interface CatalogGroup {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
schemaVersion: number;
|
||||
generatedAt: string;
|
||||
counts: {
|
||||
total: number;
|
||||
vendor: number;
|
||||
native: number;
|
||||
triggers: number;
|
||||
fields: number;
|
||||
};
|
||||
groups: CatalogGroup[];
|
||||
components: CatalogComponent[];
|
||||
}
|
||||
|
||||
export interface FlowVariable {
|
||||
name: string;
|
||||
type: string;
|
||||
defaultValue?: string;
|
||||
description?: string;
|
||||
scope?: "flow" | "local";
|
||||
}
|
||||
|
||||
export interface FlowNodeOptions {
|
||||
outputVar?: string;
|
||||
delayBeforeMs?: number;
|
||||
delayAfterMs?: number;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
retryIntervalMs?: number;
|
||||
skipOnError?: boolean;
|
||||
logException?: boolean;
|
||||
}
|
||||
|
||||
export interface FlowNode {
|
||||
id: string;
|
||||
componentId: string;
|
||||
label?: string;
|
||||
position: { x: number; y: number };
|
||||
values: Record<string, unknown>;
|
||||
options: FlowNodeOptions;
|
||||
parentId?: string | null;
|
||||
slot?: string;
|
||||
enabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface FlowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface FlowDefinition {
|
||||
schemaVersion: 1;
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
variables: FlowVariable[];
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
meta: {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
appVersion: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FlowSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
updatedAt: string;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export interface AppInfo {
|
||||
version: string;
|
||||
electron: string;
|
||||
chrome: string;
|
||||
node: string;
|
||||
userData: string;
|
||||
catalogPath: string;
|
||||
packaged: boolean;
|
||||
}
|
||||
|
||||
export interface RunNodeState {
|
||||
nodeId: string;
|
||||
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
durationMs?: number;
|
||||
output?: unknown;
|
||||
error?: RunError;
|
||||
evidence?: string[];
|
||||
attempt?: number;
|
||||
}
|
||||
|
||||
export interface RunError {
|
||||
code: string;
|
||||
reason: string;
|
||||
recommendation?: string;
|
||||
location?: { flowId?: string; nodeId?: string; componentId?: string; arg?: string };
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RunEvent {
|
||||
type:
|
||||
| "run.started"
|
||||
| "node.started"
|
||||
| "node.log"
|
||||
| "node.finished"
|
||||
| "run.finished";
|
||||
runId: string;
|
||||
nodeId?: string;
|
||||
level?: "info" | "warn" | "error";
|
||||
message?: string;
|
||||
status?: string;
|
||||
output?: unknown;
|
||||
error?: RunError;
|
||||
durationMs?: number;
|
||||
evidence?: string[];
|
||||
attempt?: number;
|
||||
startedAt?: string;
|
||||
}
|
||||
Reference in new issue
Block a user