Files
qihuaiflow/app/frontend/src/pages/admin/Workflow.tsx
T

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",
};
}