diff --git a/src/pages/scenario/flowgram/Editor.tsx b/src/pages/scenario/flowgram/Editor.tsx new file mode 100644 index 00000000..db98abc7 --- /dev/null +++ b/src/pages/scenario/flowgram/Editor.tsx @@ -0,0 +1,441 @@ +import React, { useRef, useState, useCallback, useEffect } from 'react'; +import { Button, Space, message, Drawer, Form, Input, Select, InputNumber } from 'antd'; +import { + PlusOutlined, + PlayCircleOutlined, + SaveOutlined, + DeleteOutlined, +} from '@ant-design/icons'; +import { FreeLayoutEditor } from '@flowgram.ai/free-layout-editor'; +import { nanoid } from 'nanoid'; +import type { FlowgramGraph, FlowgramNode, FlowgramEdge } from './types'; +import { nodeTemplates } from './nodes'; +import { flowgramToBackend, backendToFlowgram, validateWorkflow, createEdge } from './utils/dataConverter'; +import './editor.css'; + +const { Option } = Select; + +interface FlowgramEditorProps { + value?: FlowgramGraph; + onChange?: (graph: FlowgramGraph) => void; +} + +export const FlowgramEditor: React.FC = ({ value, onChange }) => { + const editorRef = useRef(null); + const [graph, setGraph] = useState(value || { nodes: [], edges: [] }); + const [selectedNode, setSelectedNode] = useState(null); + const [drawerVisible, setDrawerVisible] = useState(false); + const [form] = Form.useForm(); + + // Initialize editor + useEffect(() => { + if (value) { + setGraph(backendToFlowgram(value)); + } + }, [value]); + + // Notify parent of changes + const notifyChange = useCallback((newGraph: FlowgramGraph) => { + setGraph(newGraph); + if (onChange) { + onChange(flowgramToBackend(newGraph)); + } + }, [onChange]); + + // Add node to canvas + const addNode = useCallback((templateKey: string) => { + const template = nodeTemplates[templateKey as keyof typeof nodeTemplates]; + if (!template) { + message.error('未知的节点类型'); + return; + } + + const newNode = template(); + // Position new node in center with some randomness + newNode.position = { + x: 200 + Math.random() * 300, + y: 100 + Math.random() * 200, + }; + + const newGraph = { + ...graph, + nodes: [...graph.nodes, newNode], + }; + + notifyChange(newGraph); + message.success('节点已添加'); + }, [graph, notifyChange]); + + // Handle node click/double-click to open config + const handleNodeClick = useCallback((node: FlowgramNode) => { + setSelectedNode(node); + form.setFieldsValue(node.data); + setDrawerVisible(true); + }, [form]); + + // Save node configuration + const handleSaveNodeConfig = useCallback(() => { + if (!selectedNode) return; + + form.validateFields().then((values) => { + const updatedNodes = graph.nodes.map((node) => + node.id === selectedNode.id + ? { ...node, data: { ...node.data, ...values } } + : node + ); + + const newGraph = { + ...graph, + nodes: updatedNodes, + }; + + notifyChange(newGraph); + setDrawerVisible(false); + message.success('节点配置已保存'); + }); + }, [selectedNode, form, graph, notifyChange]); + + // Delete selected node + const handleDeleteNode = useCallback(() => { + if (!selectedNode) return; + + const newGraph = { + nodes: graph.nodes.filter((n) => n.id !== selectedNode.id), + edges: graph.edges.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id), + }; + + notifyChange(newGraph); + setDrawerVisible(false); + message.success('节点已删除'); + }, [selectedNode, graph, notifyChange]); + + // Connect two nodes + const handleConnect = useCallback((connection: { source: string; target: string }) => { + const edge = createEdge(connection.source, connection.target); + const newGraph = { + ...graph, + edges: [...graph.edges, edge], + }; + + notifyChange(newGraph); + }, [graph, notifyChange]); + + // Validate workflow + const handleValidate = useCallback(() => { + const validation = validateWorkflow(graph); + if (validation.valid) { + message.success('工作流验证通过!'); + } else { + message.error(`验证失败: ${validation.errors.join(', ')}`); + } + }, [graph]); + + // Render node configuration form based on node type + const renderNodeConfigForm = () => { + if (!selectedNode) return null; + + const { type, data } = selectedNode; + + switch (type) { + case 'trigger': + return ( + <> + + + + + + + {data.triggerType === 'time' && ( + + + + )} + {data.triggerType === 'variable_monitor' && ( + + + + )} + {data.triggerType === 'system_event' && ( + <> + + + + + + + + )} + + ); + + case 'condition': + return ( + <> + + + + + + + + + + + + + + ); + + case 'action': + return ( + <> + + + + + + + {data.actionType === 'run_task' && ( + + + + )} + {data.actionType === 'set_variable' && ( + <> + + + + + + + + )} + {data.actionType === 'execute_command' && ( + + + + )} + {data.actionType === 'send_notification' && ( + + + + )} + + ); + + case 'control': + return ( + <> + + + + + + + {data.controlType === 'delay' && ( + + + + )} + {data.controlType === 'retry' && ( + <> + + + + + + + + + + + )} + {data.controlType === 'circuit_breaker' && ( + + + + )} + + ); + + case 'logic_gate': + return ( + <> + + + + + + + + ); + + default: + return ( + + + + ); + } + }; + + // Simple render for now - full Flowgram integration would go here + return ( +
+ {/* Toolbar */} +
+ + + + + + + + + + +
+ + {/* Canvas area - simplified view showing nodes */} +
+
+

工作流节点 ({graph.nodes.length})

+ + {graph.nodes.map((node) => ( +
handleNodeClick(node)} + style={{ + padding: '10px', + border: '1px solid #d9d9d9', + borderRadius: '4px', + cursor: 'pointer', + background: '#fff', + }} + > +
{node.data.label}
+
+ 类型: {node.type} | ID: {node.id} +
+
+ ))} +
+ + {graph.edges.length > 0 && ( + <> +

连接 ({graph.edges.length})

+ + {graph.edges.map((edge) => ( +
+ {edge.source} → {edge.target} +
+ ))} +
+ + )} +
+
+ + {/* Node configuration drawer */} + setDrawerVisible(false)} + open={drawerVisible} + extra={ + + + + + } + > +
+ {renderNodeConfigForm()} +
+
+
+ ); +}; + +export default FlowgramEditor; diff --git a/src/pages/scenario/flowgram/editor.css b/src/pages/scenario/flowgram/editor.css new file mode 100644 index 00000000..2f83d416 --- /dev/null +++ b/src/pages/scenario/flowgram/editor.css @@ -0,0 +1,31 @@ +.flowgram-editor-container { + display: flex; + flex-direction: column; + height: 600px; + border: 1px solid #d9d9d9; + border-radius: 4px; + background: #fafafa; +} + +.flowgram-toolbar { + padding: 12px; + background: #fff; + border-bottom: 1px solid #d9d9d9; + z-index: 10; +} + +.flowgram-canvas { + flex: 1; + overflow: auto; + background: #f5f5f5; + position: relative; +} + +.node-card { + transition: all 0.3s; +} + +.node-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transform: translateY(-2px); +} diff --git a/src/pages/scenario/flowgram/nodes/index.ts b/src/pages/scenario/flowgram/nodes/index.ts new file mode 100644 index 00000000..7a23d741 --- /dev/null +++ b/src/pages/scenario/flowgram/nodes/index.ts @@ -0,0 +1,153 @@ +// Flowgram Node Registry +import { nanoid } from 'nanoid'; +import type { + StartNodeData, + TriggerNodeData, + ConditionNodeData, + ActionNodeData, + ControlNodeData, + LogicGateNodeData, + EndNodeData, + FlowgramNode +} from '../types'; + +// Node creation helpers +export function createStartNode(x: number = 100, y: number = 100): FlowgramNode { + return { + id: `start-${nanoid(8)}`, + type: 'start', + position: { x, y }, + data: { + label: '开始', + } as StartNodeData, + }; +} + +export function createTriggerNode(triggerType: string, x: number = 200, y: number = 100): FlowgramNode { + const labels: Record = { + time: '时间触发', + webhook: 'Webhook触发', + variable_monitor: '变量监听', + task_status: '任务状态', + system_event: '系统事件', + }; + + return { + id: `trigger-${nanoid(8)}`, + type: 'trigger', + position: { x, y }, + data: { + label: labels[triggerType] || '触发器', + triggerType, + config: {}, + } as TriggerNodeData, + }; +} + +export function createConditionNode(x: number = 300, y: number = 100): FlowgramNode { + return { + id: `condition-${nanoid(8)}`, + type: 'condition', + position: { x, y }, + data: { + label: '条件判断', + operator: 'equals', + field: '', + value: '', + } as ConditionNodeData, + }; +} + +export function createActionNode(actionType: string, x: number = 400, y: number = 100): FlowgramNode { + const labels: Record = { + run_task: '运行任务', + set_variable: '设置变量', + execute_command: '执行命令', + send_notification: '发送通知', + }; + + return { + id: `action-${nanoid(8)}`, + type: 'action', + position: { x, y }, + data: { + label: labels[actionType] || '动作', + actionType, + } as ActionNodeData, + }; +} + +export function createControlNode(controlType: string, x: number = 500, y: number = 100): FlowgramNode { + const labels: Record = { + delay: '延迟执行', + retry: '重试策略', + circuit_breaker: '熔断器', + }; + + return { + id: `control-${nanoid(8)}`, + type: 'control', + position: { x, y }, + data: { + label: labels[controlType] || '控制流', + controlType, + } as ControlNodeData, + }; +} + +export function createLogicGateNode(gateType: 'AND' | 'OR', x: number = 300, y: number = 200): FlowgramNode { + return { + id: `gate-${nanoid(8)}`, + type: 'logic_gate', + position: { x, y }, + data: { + label: gateType === 'AND' ? '逻辑与' : '逻辑或', + gateType, + } as LogicGateNodeData, + }; +} + +export function createEndNode(x: number = 600, y: number = 100): FlowgramNode { + return { + id: `end-${nanoid(8)}`, + type: 'end', + position: { x, y }, + data: { + label: '结束', + } as EndNodeData, + }; +} + +// Node templates for quick creation +export const nodeTemplates = { + start: () => createStartNode(), + + // Trigger templates + 'trigger-time': () => createTriggerNode('time'), + 'trigger-webhook': () => createTriggerNode('webhook'), + 'trigger-variable': () => createTriggerNode('variable_monitor'), + 'trigger-task': () => createTriggerNode('task_status'), + 'trigger-system': () => createTriggerNode('system_event'), + + // Condition template + condition: () => createConditionNode(), + + // Action templates + 'action-run': () => createActionNode('run_task'), + 'action-variable': () => createActionNode('set_variable'), + 'action-command': () => createActionNode('execute_command'), + 'action-notify': () => createActionNode('send_notification'), + + // Control templates + 'control-delay': () => createControlNode('delay'), + 'control-retry': () => createControlNode('retry'), + 'control-breaker': () => createControlNode('circuit_breaker'), + + // Logic gate templates + 'gate-and': () => createLogicGateNode('AND'), + 'gate-or': () => createLogicGateNode('OR'), + + end: () => createEndNode(), +}; + +export type NodeTemplate = keyof typeof nodeTemplates; diff --git a/src/pages/scenario/flowgram/types.ts b/src/pages/scenario/flowgram/types.ts new file mode 100644 index 00000000..c971243b --- /dev/null +++ b/src/pages/scenario/flowgram/types.ts @@ -0,0 +1,76 @@ +// Flowgram Types for Qinglong Scenario Mode + +export interface FlowgramNodeData { + label: string; + [key: string]: any; +} + +export interface FlowgramNode { + id: string; + type: string; + position: { x: number; y: number }; + data: FlowgramNodeData; +} + +export interface FlowgramEdge { + id: string; + source: string; + target: string; + [key: string]: any; +} + +export interface FlowgramGraph { + nodes: FlowgramNode[]; + edges: FlowgramEdge[]; +} + +// Node type definitions +export type NodeType = 'start' | 'trigger' | 'condition' | 'action' | 'control' | 'logic_gate' | 'end'; + +export type TriggerType = 'time' | 'webhook' | 'variable_monitor' | 'task_status' | 'system_event'; +export type ConditionOperator = 'equals' | 'not_equals' | 'greater_than' | 'less_than' | 'contains' | 'not_contains'; +export type ActionType = 'run_task' | 'set_variable' | 'execute_command' | 'send_notification'; +export type ControlType = 'delay' | 'retry' | 'circuit_breaker'; +export type LogicGateType = 'AND' | 'OR'; + +// Node data interfaces +export interface StartNodeData extends FlowgramNodeData { + label: string; +} + +export interface TriggerNodeData extends FlowgramNodeData { + triggerType: TriggerType; + config?: any; +} + +export interface ConditionNodeData extends FlowgramNodeData { + operator: ConditionOperator; + field: string; + value: any; +} + +export interface ActionNodeData extends FlowgramNodeData { + actionType: ActionType; + cronId?: number; + name?: string; + value?: string; + command?: string; + message?: string; +} + +export interface ControlNodeData extends FlowgramNodeData { + controlType: ControlType; + delaySeconds?: number; + maxRetries?: number; + retryDelay?: number; + backoffMultiplier?: number; + failureThreshold?: number; +} + +export interface LogicGateNodeData extends FlowgramNodeData { + gateType: LogicGateType; +} + +export interface EndNodeData extends FlowgramNodeData { + label: string; +} diff --git a/src/pages/scenario/flowgram/utils/dataConverter.ts b/src/pages/scenario/flowgram/utils/dataConverter.ts new file mode 100644 index 00000000..7fed93af --- /dev/null +++ b/src/pages/scenario/flowgram/utils/dataConverter.ts @@ -0,0 +1,118 @@ +// Data format converter between Flowgram and backend +import type { FlowgramGraph, FlowgramNode, FlowgramEdge } from '../types'; + +/** + * Convert Flowgram graph to backend format + * Flowgram uses a similar node-edge structure, so minimal conversion needed + */ +export function flowgramToBackend(flowgramGraph: FlowgramGraph): any { + return { + nodes: flowgramGraph.nodes.map((node) => ({ + id: node.id, + type: node.type, + position: node.position, + data: node.data, + })), + edges: flowgramGraph.edges.map((edge) => ({ + id: edge.id, + source: edge.source, + target: edge.target, + })), + }; +} + +/** + * Convert backend format to Flowgram graph + */ +export function backendToFlowgram(backendGraph: any): FlowgramGraph { + if (!backendGraph || !backendGraph.nodes) { + return { nodes: [], edges: [] }; + } + + return { + nodes: backendGraph.nodes.map((node: any) => ({ + id: node.id, + type: node.type, + position: node.position || { x: 0, y: 0 }, + data: node.data || {}, + })), + edges: backendGraph.edges || [], + }; +} + +/** + * Create a new edge between two nodes + */ +export function createEdge(source: string, target: string, id?: string): FlowgramEdge { + return { + id: id || `edge-${source}-${target}`, + source, + target, + }; +} + +/** + * Validate workflow graph structure + */ +export function validateWorkflow(graph: FlowgramGraph): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check for at least one node + if (!graph.nodes || graph.nodes.length === 0) { + errors.push('工作流至少需要一个节点'); + } + + // Check for trigger or start node + const hasTrigger = graph.nodes.some((n) => n.type === 'trigger' || n.type === 'start'); + if (!hasTrigger) { + errors.push('工作流需要至少一个触发器或开始节点'); + } + + // Check for cycles (simple check) + const visited = new Set(); + const recStack = new Set(); + + function hasCycle(nodeId: string): boolean { + visited.add(nodeId); + recStack.add(nodeId); + + const outgoingEdges = graph.edges.filter((e) => e.source === nodeId); + for (const edge of outgoingEdges) { + if (!visited.has(edge.target)) { + if (hasCycle(edge.target)) return true; + } else if (recStack.has(edge.target)) { + return true; + } + } + + recStack.delete(nodeId); + return false; + } + + for (const node of graph.nodes) { + if (!visited.has(node.id) && hasCycle(node.id)) { + errors.push('工作流包含循环依赖'); + break; + } + } + + // Check for disconnected nodes + const connectedNodes = new Set(); + graph.edges.forEach((edge) => { + connectedNodes.add(edge.source); + connectedNodes.add(edge.target); + }); + + const disconnectedNodes = graph.nodes.filter( + (node) => !connectedNodes.has(node.id) && graph.nodes.length > 1 + ); + + if (disconnectedNodes.length > 0) { + errors.push(`发现 ${disconnectedNodes.length} 个未连接的节点`); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/src/pages/scenario/flowgramWorkflowModal.tsx b/src/pages/scenario/flowgramWorkflowModal.tsx new file mode 100644 index 00000000..e7cbc4f8 --- /dev/null +++ b/src/pages/scenario/flowgramWorkflowModal.tsx @@ -0,0 +1,107 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Input, Switch, message } from 'antd'; +import FlowgramEditor from './flowgram/Editor'; +import type { FlowgramGraph } from './flowgram/types'; + +interface FlowgramWorkflowModalProps { + visible: boolean; + scenario?: any; + onCancel: () => void; + onOk: (values: any) => void; +} + +const FlowgramWorkflowModal: React.FC = ({ + visible, + scenario, + onCancel, + onOk, +}) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [workflowGraph, setWorkflowGraph] = useState(); + + useEffect(() => { + if (visible && scenario) { + form.setFieldsValue({ + name: scenario.name, + isDisabled: scenario.isDisabled === 1, + }); + if (scenario.workflowGraph) { + setWorkflowGraph(scenario.workflowGraph); + } + } else if (visible) { + form.resetFields(); + setWorkflowGraph(undefined); + } + }, [visible, scenario, form]); + + const handleOk = async () => { + try { + const values = await form.validateFields(); + + // Validate workflow has nodes + if (!workflowGraph || workflowGraph.nodes.length === 0) { + message.error('请添加至少一个节点到工作流'); + return; + } + + setLoading(true); + + const submitData = { + ...values, + workflowGraph, + isDisabled: values.isDisabled ? 1 : 0, + }; + + if (scenario) { + submitData.id = scenario.id; + } + + await onOk(submitData); + form.resetFields(); + setWorkflowGraph(undefined); + } catch (error) { + console.error('表单验证失败:', error); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + setWorkflowGraph(undefined); + onCancel(); + }; + + return ( + +
+ + + + + + + + + + + +
+
+ ); +}; + +export default FlowgramWorkflowModal; diff --git a/src/pages/scenario/index.tsx b/src/pages/scenario/index.tsx index 62870ced..7c0f68e9 100644 --- a/src/pages/scenario/index.tsx +++ b/src/pages/scenario/index.tsx @@ -23,7 +23,7 @@ import { } from '@ant-design/icons'; import { request } from '@/utils/http'; import intl from 'react-intl-universal'; -import ScenarioModal from './visualWorkflowModal'; +import ScenarioModal from './flowgramWorkflowModal'; import ScenarioLogModal from './logModal'; import dayjs from 'dayjs';