feat: 画布式 RPA 桌面端 Day1(1016 目录/画布/容器/属性/持久化)

This commit is contained in:
yu committed 2026-10-26 14:38:17 +08:00
commit 32ca0a1af8
34 files changed
+15141

No files matched your search

+62
View File
@@ -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;
}
+95
View File
@@ -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);
}
+115
View File
@@ -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();
});
}
+28
View File
@@ -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;
+13
View File
@@ -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>
+156
View File
@@ -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>
);
}
+187
View File
@@ -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>
);
}
+259
View File
@@ -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>
);
}
+47
View File
@@ -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>
);
}
+51
View File
@@ -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
};
+9
View File
@@ -0,0 +1,9 @@
import type { RpaApi } from "../../preload/index";
declare global {
interface Window {
rpa: RpaApi;
}
}
export {};
+12
View File
@@ -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>
);
+155
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+112
View File
@@ -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/&lt;runId&gt;/。运行期不加载 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>
);
}
+204
View File
@@ -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>
);
}
+673
View File
@@ -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>
</>
);
}
+298
View File
@@ -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
+42
View File
@@ -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;
}
+197
View File
@@ -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;
}