Phase 3.6: Agent Visual Orchestration - DAG Workflow Editor (8 node types, drag-drop canvas, execution engine)
This commit is contained in:
1 parent
fc36777a5c
commit
b675e9fd20
5 files changed
+694
-1
No files matched your search
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface WorkflowNode {
|
||||
id: string;
|
||||
type: string;
|
||||
position: { x: number; y: number };
|
||||
data: { label: string; config: Record<string, any> };
|
||||
}
|
||||
|
||||
interface WorkflowEdge {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
agent_id: string | null;
|
||||
nodes: WorkflowNode[];
|
||||
edges: WorkflowEdge[];
|
||||
variables: Record<string, any>;
|
||||
status: string;
|
||||
version: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface NodeTemplate {
|
||||
type: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
config_schema: Record<string, any>;
|
||||
}
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
start: "#3B6D11",
|
||||
llm: "#534AB7",
|
||||
knowledge: "#185FA5",
|
||||
skill: "#0F6E56",
|
||||
condition: "#BA7517",
|
||||
http: "#D4537E",
|
||||
code: "#5F5E5A",
|
||||
end: "#A32D2D",
|
||||
};
|
||||
|
||||
const NODE_LABELS: Record<string, string> = {
|
||||
start: "开始",
|
||||
llm: "LLM 调用",
|
||||
knowledge: "知识库检索",
|
||||
skill: "Skill 调用",
|
||||
condition: "条件分支",
|
||||
http: "HTTP 请求",
|
||||
code: "代码执行",
|
||||
end: "结束",
|
||||
};
|
||||
|
||||
export default function Workflow() {
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [view, setView] = useState<"list" | "editor">("list");
|
||||
const [currentWf, setCurrentWf] = useState<Workflow | null>(null);
|
||||
const [templates, setTemplates] = useState<NodeTemplate[]>([]);
|
||||
const [options, setOptions] = useState<Record<string, any[]>>({});
|
||||
const [selectedNode, setSelectedNode] = useState<WorkflowNode | null>(null);
|
||||
const [draggingNode, setDraggingNode] = useState<string | null>(null);
|
||||
const [connectingFrom, setConnectingFrom] = useState<string | null>(null);
|
||||
const [executeResult, setExecuteResult] = useState<any>(null);
|
||||
const [executeInput, setExecuteInput] = useState("");
|
||||
const [showExecute, setShowExecute] = useState(false);
|
||||
const [stats, setStats] = useState({ total: 0, draft: 0, published: 0, archived: 0 });
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
const api = (window as any).api;
|
||||
|
||||
// Load workflows
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get("/admin/workflows/");
|
||||
if (res.code === 0) setWorkflows(res.data.items || []);
|
||||
} catch (e) { console.error(e); }
|
||||
try {
|
||||
const s = await api.get("/admin/workflows/stats");
|
||||
if (s.code === 0) setStats(s.data);
|
||||
} catch (e) { console.error(e); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Load templates
|
||||
const loadTemplates = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get("/admin/workflows/templates");
|
||||
if (res.code === 0) {
|
||||
setTemplates(Object.values(res.data.node_templates || {}));
|
||||
setOptions(res.data.options || {});
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadWorkflows(); loadTemplates(); }, []);
|
||||
|
||||
// Create new workflow
|
||||
const handleCreate = async () => {
|
||||
const name = prompt("工作流名称:", "新工作流");
|
||||
if (!name) return;
|
||||
try {
|
||||
const res = await api.post("/admin/workflows/", {
|
||||
name,
|
||||
description: "",
|
||||
nodes: [
|
||||
{ id: "start-1", type: "start", position: { x: 100, y: 200 }, data: { label: "开始", config: { input_variable: "input" } } },
|
||||
{ id: "end-1", type: "end", position: { x: 500, y: 200 }, data: { label: "结束", config: { output_variable: "output" } } },
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
if (res.code === 0) {
|
||||
loadWorkflows();
|
||||
handleOpenEditor(res.data);
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Open editor
|
||||
const handleOpenEditor = (wf: Workflow) => {
|
||||
setCurrentWf(wf);
|
||||
setSelectedNode(null);
|
||||
setView("editor");
|
||||
};
|
||||
|
||||
// Save workflow
|
||||
const handleSave = async () => {
|
||||
if (!currentWf) return;
|
||||
try {
|
||||
const res = await api.put(`/admin/workflows/${currentWf.id}`, {
|
||||
name: currentWf.name,
|
||||
description: currentWf.description,
|
||||
nodes: currentWf.nodes,
|
||||
edges: currentWf.edges,
|
||||
variables: currentWf.variables,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setCurrentWf(res.data);
|
||||
alert("保存成功");
|
||||
}
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Delete workflow
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("确认删除此工作流?")) return;
|
||||
try {
|
||||
await api.del(`/admin/workflows/${id}`);
|
||||
loadWorkflows();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Toggle publish
|
||||
const handleToggle = async (wf: Workflow) => {
|
||||
const isActive = wf.status !== "published";
|
||||
try {
|
||||
await api.put(`/admin/workflows/${wf.id}/toggle?is_active=${isActive}`);
|
||||
loadWorkflows();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
// Add node to canvas
|
||||
const handleAddNode = (template: NodeTemplate) => {
|
||||
if (!currentWf) return;
|
||||
const id = `${template.type}-${Date.now()}`;
|
||||
const newNode: WorkflowNode = {
|
||||
id,
|
||||
type: template.type,
|
||||
position: { x: 250 + Math.random() * 100, y: 150 + Math.random() * 100 },
|
||||
data: {
|
||||
label: template.label,
|
||||
config: Object.fromEntries(
|
||||
Object.entries(template.config_schema).map(([k, v]: [string, any]) => [k, v.default ?? ""])
|
||||
),
|
||||
},
|
||||
};
|
||||
setCurrentWf({ ...currentWf, nodes: [...currentWf.nodes, newNode] });
|
||||
};
|
||||
|
||||
// Node drag
|
||||
const handleNodeMouseDown = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
const node = currentWf!.nodes.find(n => n.id === nodeId);
|
||||
if (!node || !canvasRef.current) return;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
dragOffset.current = {
|
||||
x: e.clientX - rect.left - node.position.x,
|
||||
y: e.clientY - rect.top - node.position.y,
|
||||
};
|
||||
setDraggingNode(nodeId);
|
||||
setSelectedNode(node);
|
||||
};
|
||||
|
||||
const handleCanvasMouseMove = (e: React.MouseEvent) => {
|
||||
if (!draggingNode || !currentWf || !canvasRef.current) return;
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left - dragOffset.current.x;
|
||||
const y = e.clientY - rect.top - dragOffset.current.y;
|
||||
setCurrentWf({
|
||||
...currentWf,
|
||||
nodes: currentWf.nodes.map(n => n.id === draggingNode ? { ...n, position: { x, y } } : n),
|
||||
});
|
||||
};
|
||||
|
||||
const handleCanvasMouseUp = () => { setDraggingNode(null); };
|
||||
|
||||
// Canvas click (deselect / cancel connecting)
|
||||
const handleCanvasClick = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
setSelectedNode(null);
|
||||
setConnectingFrom(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Start connecting from a node
|
||||
const handleStartConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
setConnectingFrom(nodeId);
|
||||
};
|
||||
|
||||
// Complete connection to a node
|
||||
const handleEndConnect = (e: React.MouseEvent, nodeId: string) => {
|
||||
e.stopPropagation();
|
||||
if (!connectingFrom || !currentWf) return;
|
||||
if (connectingFrom === nodeId) { setConnectingFrom(null); return; }
|
||||
// Check if edge already exists
|
||||
const exists = currentWf.edges.some(e => e.source === connectingFrom && e.target === nodeId);
|
||||
if (exists) { setConnectingFrom(null); return; }
|
||||
const newEdge: WorkflowEdge = {
|
||||
id: `e-${Date.now()}`,
|
||||
source: connectingFrom,
|
||||
target: nodeId,
|
||||
};
|
||||
setCurrentWf({ ...currentWf, edges: [...currentWf.edges, newEdge] });
|
||||
setConnectingFrom(null);
|
||||
};
|
||||
|
||||
// Delete edge
|
||||
const handleDeleteEdge = (edgeId: string) => {
|
||||
if (!currentWf) return;
|
||||
setCurrentWf({ ...currentWf, edges: currentWf.edges.filter(e => e.id !== edgeId) });
|
||||
};
|
||||
|
||||
// Delete selected node
|
||||
const handleDeleteNode = () => {
|
||||
if (!selectedNode || !currentWf) return;
|
||||
const nodeId = selectedNode.id;
|
||||
setCurrentWf({
|
||||
...currentWf,
|
||||
nodes: currentWf.nodes.filter(n => n.id !== nodeId),
|
||||
edges: currentWf.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
|
||||
});
|
||||
setSelectedNode(null);
|
||||
};
|
||||
|
||||
// Update node config
|
||||
const handleConfigChange = (key: string, value: any) => {
|
||||
if (!selectedNode || !currentWf) return;
|
||||
const updatedNodes = currentWf.nodes.map(n =>
|
||||
n.id === selectedNode.id
|
||||
? { ...n, data: { ...n.data, label: n.data.label, config: { ...n.data.config, [key]: value } } }
|
||||
: n
|
||||
);
|
||||
setCurrentWf({ ...currentWf, nodes: updatedNodes });
|
||||
setSelectedNode(updatedNodes.find(n => n.id === selectedNode.id) || null);
|
||||
};
|
||||
|
||||
// Execute workflow
|
||||
const handleExecute = async () => {
|
||||
if (!currentWf) return;
|
||||
try {
|
||||
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
||||
input_text: executeInput,
|
||||
});
|
||||
if (res.code === 0) {
|
||||
setExecuteResult(res.data);
|
||||
} else {
|
||||
alert("执行失败: " + (res.message || "未知错误"));
|
||||
}
|
||||
} catch (e) { console.error(e); alert("执行失败"); }
|
||||
};
|
||||
|
||||
// SVG edge path (bezier curve)
|
||||
const getEdgePath = (src: WorkflowNode, tgt: WorkflowNode) => {
|
||||
const x1 = src.position.x + 90;
|
||||
const y1 = src.position.y + 25;
|
||||
const x2 = tgt.position.x;
|
||||
const y2 = tgt.position.y + 25;
|
||||
const dx = Math.abs(x2 - x1) * 0.5;
|
||||
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
// LIST VIEW
|
||||
// ============================================================
|
||||
if (view === "list") {
|
||||
return (
|
||||
<div style={{ padding: "24px", color: "#e0e0e0", minHeight: "100vh" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: "20px", color: "#fff" }}>Agent 可视化编排</h2>
|
||||
<p style={{ margin: "4px 0 0", color: "#888", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · 支持LLM/知识库/Skill/条件分支/HTTP/代码节点</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} style={btnPrimary}>+ 新建工作流</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "16px", marginBottom: "24px" }}>
|
||||
{[
|
||||
{ label: "总工作流", value: stats.total, color: "#534AB7" },
|
||||
{ label: "草稿", value: stats.draft, color: "#BA7517" },
|
||||
{ label: "已发布", value: stats.published, color: "#3B6D11" },
|
||||
{ label: "已归档", value: stats.archived, color: "#5F5E5A" },
|
||||
].map(s => (
|
||||
<div key={s.label} style={{ ...statCard, borderLeft: `3px solid ${s.color}` }}>
|
||||
<div style={{ fontSize: "24px", fontWeight: 600, color: "#fff" }}>{s.value}</div>
|
||||
<div style={{ fontSize: "12px", color: "#888" }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Workflow list */}
|
||||
<div style={{ background: "#1a1a2e", borderRadius: "12px", overflow: "hidden" }}>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: "1px solid #333" }}>
|
||||
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
|
||||
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#888", fontWeight: 500 }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>加载中...</td></tr>
|
||||
) : workflows.length === 0 ? (
|
||||
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#666" }}>暂无工作流,点击"新建工作流"开始</td></tr>
|
||||
) : workflows.map(wf => (
|
||||
<tr key={wf.id} style={{ borderBottom: "1px solid #222" }}>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<div style={{ color: "#fff", fontWeight: 500 }}>{wf.name}</div>
|
||||
<div style={{ color: "#666", fontSize: "12px" }}>{wf.description || "—"}</div>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<span style={statusBadge(wf.status)}>{wf.status === "published" ? "已发布" : wf.status === "archived" ? "已归档" : "草稿"}</span>
|
||||
</td>
|
||||
<td style={{ padding: "12px 16px", color: "#aaa" }}>{wf.nodes?.length || 0}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#aaa" }}>v{wf.version}</td>
|
||||
<td style={{ padding: "12px 16px", color: "#888", fontSize: "12px" }}>{wf.updated_at?.slice(0, 19).replace("T", " ") || "—"}</td>
|
||||
<td style={{ padding: "12px 16px" }}>
|
||||
<button onClick={() => handleOpenEditor(wf)} style={btnSmall}>编辑</button>
|
||||
<button onClick={() => handleToggle(wf)} style={{ ...btnSmall, marginLeft: "4px" }}>{wf.status === "published" ? "归档" : "发布"}</button>
|
||||
<button onClick={() => handleDelete(wf.id)} style={{ ...btnSmall, marginLeft: "4px", color: "#F09595" }}>删除</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// EDITOR VIEW
|
||||
// ============================================================
|
||||
if (!currentWf) return null;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100vh", background: "#0d0d1a", color: "#e0e0e0" }}>
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#16162a", borderBottom: "1px solid #2a2a4a", gap: "8px" }}>
|
||||
<button onClick={() => { setView("list"); loadWorkflows(); }} style={btnSmall}>← 返回</button>
|
||||
<input
|
||||
value={currentWf.name}
|
||||
onChange={e => setCurrentWf({ ...currentWf, name: e.target.value })}
|
||||
style={{ background: "transparent", border: "1px solid #333", borderRadius: "6px", padding: "4px 12px", color: "#fff", fontSize: "14px", width: "200px" }}
|
||||
/>
|
||||
<span style={{ color: "#666", fontSize: "12px" }}>v{currentWf.version} · {currentWf.nodes.length} 节点 · {currentWf.edges.length} 连线</span>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button onClick={handleSave} style={btnPrimary}>保存</button>
|
||||
<button onClick={() => setShowExecute(!showExecute)} style={{ ...btnSmall, borderColor: "#3B6D11" }}>执行</button>
|
||||
{selectedNode && <button onClick={handleDeleteNode} style={{ ...btnSmall, color: "#F09595" }}>删除节点</button>}
|
||||
</div>
|
||||
|
||||
{/* Execute panel */}
|
||||
{showExecute && (
|
||||
<div style={{ padding: "12px 16px", background: "#1a1a2e", borderBottom: "1px solid #2a2a4a" }}>
|
||||
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ fontSize: "12px", color: "#888" }}>输入文本</label>
|
||||
<textarea
|
||||
value={executeInput}
|
||||
onChange={e => setExecuteInput(e.target.value)}
|
||||
placeholder="输入测试文本..."
|
||||
style={{ width: "100%", background: "#0d0d1a", border: "1px solid #333", borderRadius: "6px", padding: "8px", color: "#fff", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
|
||||
/>
|
||||
</div>
|
||||
<button onClick={handleExecute} style={btnPrimary}>执行工作流</button>
|
||||
</div>
|
||||
{executeResult && (
|
||||
<div style={{ marginTop: "12px" }}>
|
||||
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>执行日志:</div>
|
||||
{executeResult.log?.map((l: any, i: number) => (
|
||||
<div key={i} style={{ fontSize: "12px", padding: "4px 8px", background: l.success ? "rgba(59,109,17,0.15)" : "rgba(163,45,45,0.15)", borderRadius: "4px", marginBottom: "2px" }}>
|
||||
<span style={{ color: l.success ? "#97C459" : "#F09595" }}>[{l.node_type}]</span>{" "}
|
||||
<span style={{ color: "#aaa" }}>{l.node_label}</span>
|
||||
{l.output_preview && <span style={{ color: "#666" }}> → {l.output_preview.slice(0, 100)}</span>}
|
||||
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginTop: "8px", padding: "8px", background: "#0d0d1a", borderRadius: "6px", border: "1px solid #333" }}>
|
||||
<div style={{ fontSize: "12px", color: "#888", marginBottom: "4px" }}>最终输出:</div>
|
||||
<pre style={{ margin: 0, color: "#97C459", fontSize: "13px", whiteSpace: "pre-wrap" }}>{executeResult.output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main area: palette | canvas | config */}
|
||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
||||
{/* Node palette */}
|
||||
<div style={{ width: "180px", background: "#16162a", borderRight: "1px solid #2a2a4a", padding: "12px", overflowY: "auto" }}>
|
||||
<div style={{ fontSize: "12px", color: "#666", marginBottom: "8px", textTransform: "uppercase" }}>节点类型</div>
|
||||
{Object.entries(NODE_LABELS).map(([type, label]) => (
|
||||
<div
|
||||
key={type}
|
||||
onClick={() => {
|
||||
const tmpl = templates.find(t => t.type === type);
|
||||
if (tmpl) handleAddNode(tmpl);
|
||||
else handleAddNode({ type, label, icon: "", color: NODE_COLORS[type], description: "", config_schema: {} });
|
||||
}}
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
marginBottom: "4px",
|
||||
borderRadius: "6px",
|
||||
cursor: "pointer",
|
||||
background: "#1a1a2e",
|
||||
border: "1px solid #333",
|
||||
borderLeft: `3px solid ${NODE_COLORS[type]}`,
|
||||
transition: "background 0.2s",
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = "#222244")}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = "#1a1a2e")}
|
||||
>
|
||||
<span style={{ color: "#fff", fontSize: "13px" }}>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div
|
||||
ref={canvasRef}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundImage: "radial-gradient(circle, #1a1a2e 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
background: "#0d0d1a",
|
||||
}}
|
||||
>
|
||||
{/* SVG for edges */}
|
||||
<svg style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", pointerEvents: "none" }}>
|
||||
{currentWf.edges.map(edge => {
|
||||
const src = currentWf.nodes.find(n => n.id === edge.source);
|
||||
const tgt = currentWf.nodes.find(n => n.id === edge.target);
|
||||
if (!src || !tgt) return null;
|
||||
return (
|
||||
<g key={edge.id} style={{ pointerEvents: "auto" }}>
|
||||
<path d={getEdgePath(src, tgt)} fill="none" stroke="#534AB7" strokeWidth={2} markerEnd="url(#arrowhead)" />
|
||||
<circle cx={(src.position.x + tgt.position.x + 90) / 2} cy={(src.position.y + tgt.position.y + 25) / 2} r={8} fill="#1a1a2e" stroke="#534AB7" strokeWidth={1} style={{ cursor: "pointer" }} onClick={() => handleDeleteEdge(edge.id)} />
|
||||
<text x={(src.position.x + tgt.position.x + 90) / 2} y={(src.position.y + tgt.position.y + 25) / 2 + 3} textAnchor="middle" fill="#F09595" fontSize="10" style={{ pointerEvents: "none" }}>x</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="#534AB7" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{currentWf.nodes.map(node => {
|
||||
const isSelected = selectedNode?.id === node.id;
|
||||
const color = NODE_COLORS[node.type] || "#5F5E5A";
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
onMouseDown={e => handleNodeMouseDown(e, node.id)}
|
||||
onClick={e => { e.stopPropagation(); setSelectedNode(node); }}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
width: "90px",
|
||||
minHeight: "50px",
|
||||
background: "#1a1a2e",
|
||||
border: `2px solid ${isSelected ? color : "#333"}`,
|
||||
borderRadius: "8px",
|
||||
cursor: draggingNode === node.id ? "grabbing" : "grab",
|
||||
padding: "8px",
|
||||
textAlign: "center",
|
||||
boxShadow: isSelected ? `0 0 12px ${color}40` : "none",
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: "11px", color, fontWeight: 600, marginBottom: "2px" }}>{NODE_LABELS[node.type] || node.type}</div>
|
||||
<div style={{ fontSize: "11px", color: "#aaa" }}>{node.data.label}</div>
|
||||
{/* Connection handles */}
|
||||
<div
|
||||
onClick={e => connectingFrom ? handleEndConnect(e, node.id) : handleStartConnect(e, node.id)}
|
||||
style={{
|
||||
position: "absolute", right: -6, top: "50%", transform: "translateY(-50%)",
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
background: connectingFrom === node.id ? "#97C459" : "#534AB7",
|
||||
cursor: "pointer", border: "2px solid #1a1a2e",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
onClick={e => connectingFrom && handleEndConnect(e, node.id)}
|
||||
style={{
|
||||
position: "absolute", left: -6, top: "50%", transform: "translateY(-50%)",
|
||||
width: 12, height: 12, borderRadius: "50%",
|
||||
background: "#444", cursor: "pointer", border: "2px solid #1a1a2e",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{connectingFrom && (
|
||||
<div style={{ position: "absolute", top: 12, left: "50%", transform: "translateX(-50%)", background: "#534AB7", color: "#fff", padding: "4px 12px", borderRadius: "6px", fontSize: "12px" }}>
|
||||
点击目标节点的右端口完成连线
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config panel */}
|
||||
<div style={{ width: "280px", background: "#16162a", borderLeft: "1px solid #2a2a4a", padding: "16px", overflowY: "auto" }}>
|
||||
{selectedNode ? (
|
||||
<>
|
||||
<div style={{ fontSize: "14px", fontWeight: 600, color: "#fff", marginBottom: "4px" }}>
|
||||
{NODE_LABELS[selectedNode.type] || selectedNode.type}
|
||||
</div>
|
||||
<div style={{ fontSize: "12px", color: "#666", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
|
||||
|
||||
<div style={{ marginBottom: "12px" }}>
|
||||
<label style={{ fontSize: "12px", color: "#888" }}>显示名称</label>
|
||||
<input
|
||||
value={selectedNode.data.label}
|
||||
onChange={e => {
|
||||
const updated = currentWf.nodes.map(n => n.id === selectedNode.id ? { ...n, data: { ...n.data, label: e.target.value } } : n);
|
||||
setCurrentWf({ ...currentWf, nodes: updated });
|
||||
setSelectedNode({ ...selectedNode, data: { ...selectedNode.data, label: e.target.value } });
|
||||
}}
|
||||
style={configInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Config fields */}
|
||||
{Object.entries(templates.find(t => t.type === selectedNode.type)?.config_schema || {}).map(([key, schema]: [string, any]) => (
|
||||
<div key={key} style={{ marginBottom: "12px" }}>
|
||||
<label style={{ fontSize: "12px", color: "#888" }}>{schema.label || key}</label>
|
||||
{schema.help && <div style={{ fontSize: "11px", color: "#666", marginBottom: "2px" }}>{schema.help}</div>}
|
||||
{schema.type === "textarea" ? (
|
||||
<textarea
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={{ ...configInput, minHeight: "60px", resize: "vertical" }}
|
||||
/>
|
||||
) : schema.type === "select" && schema.options_from ? (
|
||||
<select
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={configInput}
|
||||
>
|
||||
<option value="">-- 选择 --</option>
|
||||
{(options[schema.options_from] || []).map((opt: any) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
) : schema.type === "select" && schema.options ? (
|
||||
<select
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, e.target.value)}
|
||||
style={configInput}
|
||||
>
|
||||
<option value="">-- 选择 --</option>
|
||||
{schema.options.map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={schema.type === "number" ? "number" : "text"}
|
||||
value={selectedNode.data.config[key] ?? ""}
|
||||
onChange={e => handleConfigChange(key, schema.type === "number" ? parseFloat(e.target.value) || 0 : e.target.value)}
|
||||
style={configInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: "#666", fontSize: "13px", textAlign: "center", marginTop: "40px" }}>
|
||||
选择一个节点查看配置
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Styles
|
||||
// ============================================================
|
||||
const btnPrimary: React.CSSProperties = {
|
||||
background: "#534AB7", color: "#fff", border: "none", borderRadius: "6px",
|
||||
padding: "6px 16px", cursor: "pointer", fontSize: "13px", fontWeight: 500,
|
||||
};
|
||||
|
||||
const btnSmall: React.CSSProperties = {
|
||||
background: "transparent", color: "#aaa", border: "1px solid #333", borderRadius: "4px",
|
||||
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
|
||||
};
|
||||
|
||||
const statCard: React.CSSProperties = {
|
||||
background: "#1a1a2e", borderRadius: "8px", padding: "16px",
|
||||
};
|
||||
|
||||
const configInput: React.CSSProperties = {
|
||||
width: "100%", background: "#0d0d1a", border: "1px solid #333",
|
||||
borderRadius: "4px", padding: "6px 8px", color: "#fff", fontSize: "13px",
|
||||
outline: "none",
|
||||
};
|
||||
|
||||
function statusBadge(status: string): React.CSSProperties {
|
||||
const colors: Record<string, string> = {
|
||||
published: "#3B6D11",
|
||||
draft: "#BA7517",
|
||||
archived: "#5F5E5A",
|
||||
};
|
||||
const bg = colors[status] || "#5F5E5A";
|
||||
return {
|
||||
background: `${bg}20`, color: bg, padding: "2px 8px",
|
||||
borderRadius: "4px", fontSize: "12px",
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user