756 lines
31 KiB
TypeScript
756 lines
31 KiB
TypeScript
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",
|
|
agent: "#7C3AED",
|
|
end: "#A32D2D",
|
|
};
|
|
|
|
const NODE_LABELS: Record<string, string> = {
|
|
start: "开始",
|
|
llm: "LLM 调用",
|
|
knowledge: "知识库检索",
|
|
skill: "Skill 调用",
|
|
condition: "条件分支",
|
|
http: "HTTP 请求",
|
|
code: "代码执行",
|
|
agent: "Agent 节点",
|
|
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 [executing, setExecuting] = useState(false);
|
|
const [nodeStatus, setNodeStatus] = useState<Record<string, string>>({});
|
|
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 (supports streaming via WebSocket when available)
|
|
const handleExecute = async () => {
|
|
if (!currentWf) return;
|
|
setExecuting(true);
|
|
setExecuteResult(null);
|
|
setNodeStatus({});
|
|
|
|
// Highlight running nodes in real-time
|
|
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
const wsHost = window.location.host;
|
|
const wsUrl = `${wsProtocol}//${wsHost}/app/api/admin/workflows/${currentWf.id}/execute-stream`;
|
|
|
|
let ws: WebSocket | null = null;
|
|
let result: any = null;
|
|
|
|
try {
|
|
// Connect WebSocket
|
|
ws = new WebSocket(wsUrl);
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const msg = JSON.parse(event.data);
|
|
if (msg.type === "node_start") {
|
|
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "running" }));
|
|
// Add to log
|
|
setExecuteResult((prev: any) => ({
|
|
...prev,
|
|
log: [...(prev?.log || []), {
|
|
node_id: msg.node_id,
|
|
node_label: msg.node_label,
|
|
node_type: msg.node_type,
|
|
success: null,
|
|
}],
|
|
}));
|
|
} else if (msg.type === "node_complete") {
|
|
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "completed" }));
|
|
// Update log entry
|
|
setExecuteResult((prev: any) => {
|
|
const log = (prev?.log || []).map((l: any) =>
|
|
l.node_id === msg.node_id ? { ...l, success: true, output_preview: msg.output_preview } : l
|
|
);
|
|
return { ...prev, log };
|
|
});
|
|
} else if (msg.type === "node_error") {
|
|
setNodeStatus(prev => ({ ...prev, [msg.node_id]: "failed" }));
|
|
setExecuteResult((prev: any) => {
|
|
const log = (prev?.log || []).map((l: any) =>
|
|
l.node_id === msg.node_id ? { ...l, success: false, error: msg.error } : l
|
|
);
|
|
return { ...prev, log };
|
|
});
|
|
} else if (msg.type === "workflow_complete") {
|
|
result = msg.data;
|
|
setExecuteResult(msg.data);
|
|
} else if (msg.type === "workflow_error") {
|
|
result = msg.data;
|
|
setExecuteResult(msg.data);
|
|
}
|
|
} catch (e) { /* ignore parse errors */ }
|
|
};
|
|
|
|
ws.onopen = () => {
|
|
ws?.send(JSON.stringify({
|
|
input_text: executeInput,
|
|
token: (window as any).api?.getToken?.() || "",
|
|
}));
|
|
};
|
|
|
|
// Also call REST API as fallback
|
|
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
|
input_text: executeInput,
|
|
});
|
|
if (res.code === 0 && !result) {
|
|
setExecuteResult(res.data);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
// Try REST API fallback
|
|
try {
|
|
const res = await api.post(`/admin/workflows/${currentWf.id}/execute`, {
|
|
input_text: executeInput,
|
|
});
|
|
if (res.code === 0) setExecuteResult(res.data);
|
|
} catch (e2) { console.error(e2); }
|
|
} finally {
|
|
setExecuting(false);
|
|
setTimeout(() => {
|
|
if (ws?.readyState === WebSocket.OPEN) ws.close();
|
|
}, 1000);
|
|
}
|
|
};
|
|
|
|
// 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: "#1A1F36", minHeight: "100vh" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "24px" }}>
|
|
<div>
|
|
<h2 style={{ margin: 0, fontSize: "20px", color: "#1A1F36" }}>Agent 可视化编排</h2>
|
|
<p style={{ margin: "4px 0 0", color: "#6B7280", fontSize: "13px" }}>拖拽式 DAG 工作流编排引擎 · LLM/知识库/Skill/条件/HTTP/代码/Agent 节点 · 并行多 Agent 协作</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: "#1A1F36" }}>{s.value}</div>
|
|
<div style={{ fontSize: "12px", color: "#6B7280" }}>{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Workflow list */}
|
|
<div style={{ background: "#FFFFFF", borderRadius: "12px", overflow: "hidden" }}>
|
|
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
|
<thead>
|
|
<tr style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
|
{["名称", "状态", "节点数", "版本", "更新时间", "操作"].map(h => (
|
|
<th key={h} style={{ padding: "12px 16px", textAlign: "left", fontSize: "13px", color: "#6B7280", fontWeight: 500 }}>{h}</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>加载中...</td></tr>
|
|
) : workflows.length === 0 ? (
|
|
<tr><td colSpan={6} style={{ padding: "40px", textAlign: "center", color: "#9CA3AF" }}>暂无工作流,点击"新建工作流"开始</td></tr>
|
|
) : workflows.map(wf => (
|
|
<tr key={wf.id} style={{ borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
|
<td style={{ padding: "12px 16px" }}>
|
|
<div style={{ color: "#1A1F36", fontWeight: 500 }}>{wf.name}</div>
|
|
<div style={{ color: "#9CA3AF", 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: "#9CA3AF" }}>{wf.nodes?.length || 0}</td>
|
|
<td style={{ padding: "12px 16px", color: "#9CA3AF" }}>v{wf.version}</td>
|
|
<td style={{ padding: "12px 16px", color: "#6B7280", 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: "#F8F9FC", color: "#1A1F36" }}>
|
|
{/* Toolbar */}
|
|
<div style={{ display: "flex", alignItems: "center", padding: "8px 16px", background: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)", 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 rgba(0,0,0,0.08)", borderRadius: "6px", padding: "4px 12px", color: "#1A1F36", fontSize: "14px", width: "200px" }}
|
|
/>
|
|
<span style={{ color: "#9CA3AF", 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: "#FFFFFF", borderBottom: "1px solid rgba(0,0,0,0.08)" }}>
|
|
<div style={{ display: "flex", gap: "8px", alignItems: "flex-end" }}>
|
|
<div style={{ flex: 1 }}>
|
|
<label style={{ fontSize: "12px", color: "#6B7280" }}>输入文本</label>
|
|
<textarea
|
|
value={executeInput}
|
|
onChange={e => setExecuteInput(e.target.value)}
|
|
placeholder="输入测试文本..."
|
|
style={{ width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "6px", padding: "8px", color: "#1A1F36", fontSize: "13px", minHeight: "40px", resize: "vertical" }}
|
|
/>
|
|
</div>
|
|
<button onClick={handleExecute} style={{ ...btnPrimary, opacity: executing ? 0.6 : 1 }} disabled={executing}>
|
|
{executing ? "执行中..." : "执行工作流"}
|
|
</button>
|
|
</div>
|
|
{executing && (
|
|
<div style={{ marginTop: "8px", fontSize: "12px", color: "#534AB7" }}>
|
|
⏳ 工作流执行中...
|
|
</div>
|
|
)}
|
|
{executeResult && (
|
|
<div style={{ marginTop: "12px" }}>
|
|
<div style={{ fontSize: "12px", color: "#6B7280", 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: "#9CA3AF" }}>{l.node_label}</span>
|
|
{l.output_preview && <span style={{ color: "#9CA3AF" }}> → {l.output_preview.slice(0, 100)}</span>}
|
|
{l.error && <span style={{ color: "#F09595" }}> → {l.error}</span>}
|
|
</div>
|
|
))}
|
|
<div style={{ marginTop: "8px", padding: "8px", background: "#F8F9FC", borderRadius: "6px", border: "1px solid rgba(0,0,0,0.08)" }}>
|
|
<div style={{ fontSize: "12px", color: "#6B7280", 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: "#FFFFFF", borderRight: "1px solid rgba(0,0,0,0.08)", padding: "12px", overflowY: "auto" }}>
|
|
<div style={{ fontSize: "12px", color: "#9CA3AF", 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: "#FFFFFF",
|
|
border: "1px solid rgba(0,0,0,0.08)",
|
|
borderLeft: `3px solid ${NODE_COLORS[type]}`,
|
|
transition: "background 0.2s",
|
|
}}
|
|
onMouseEnter={e => (e.currentTarget.style.background = "#E8ECF4")}
|
|
onMouseLeave={e => (e.currentTarget.style.background = "#FFFFFF")}
|
|
>
|
|
<span style={{ color: "#1A1F36", fontSize: "13px" }}>{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Canvas */}
|
|
<div
|
|
ref={canvasRef}
|
|
onClick={handleCanvasClick}
|
|
onMouseMove={handleCanvasMouseMove}
|
|
onMouseUp={handleCanvasMouseUp}
|
|
style={{
|
|
flex: 1,
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
background: "#F8F9FC",
|
|
}}
|
|
>
|
|
{/* 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="#FFFFFF" 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 status = nodeStatus[node.id];
|
|
const baseColor = NODE_COLORS[node.type] || "#5F5E5A";
|
|
const color = status === "running" ? "#58a6ff" : status === "completed" ? "#3fb950" : status === "failed" ? "#f85149" : baseColor;
|
|
const borderWidth = isSelected || status ? 2 : 1;
|
|
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: "#FFFFFF",
|
|
border: `${borderWidth}px solid ${color}`,
|
|
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: "#9CA3AF" }}>{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 #1A1F36",
|
|
}}
|
|
/>
|
|
<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: "#9CA3AF", cursor: "pointer", border: "2px solid #FFFFFF",
|
|
}}
|
|
/>
|
|
</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: "#FFFFFF", borderLeft: "1px solid rgba(0,0,0,0.08)", padding: "16px", overflowY: "auto" }}>
|
|
{selectedNode ? (
|
|
<>
|
|
<div style={{ fontSize: "14px", fontWeight: 600, color: "#1A1F36", marginBottom: "4px" }}>
|
|
{NODE_LABELS[selectedNode.type] || selectedNode.type}
|
|
</div>
|
|
<div style={{ fontSize: "12px", color: "#9CA3AF", marginBottom: "16px" }}>节点 ID: {selectedNode.id}</div>
|
|
|
|
<div style={{ marginBottom: "12px" }}>
|
|
<label style={{ fontSize: "12px", color: "#6B7280" }}>显示名称</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: "#6B7280" }}>{schema.label || key}</label>
|
|
{schema.help && <div style={{ fontSize: "11px", color: "#9CA3AF", 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: "#9CA3AF", 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: "#9CA3AF", border: "1px solid rgba(0,0,0,0.08)", borderRadius: "4px",
|
|
padding: "4px 10px", cursor: "pointer", fontSize: "12px",
|
|
};
|
|
|
|
const statCard: React.CSSProperties = {
|
|
background: "#FFFFFF", borderRadius: "8px", padding: "16px", border: "1px solid rgba(0,0,0,0.08)",
|
|
};
|
|
|
|
const configInput: React.CSSProperties = {
|
|
width: "100%", background: "#F8F9FC", border: "1px solid rgba(0,0,0,0.08)",
|
|
borderRadius: "4px", padding: "6px 8px", color: "#1A1F36", 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",
|
|
};
|
|
}
|