|
|
@@ -0,0 +1,838 @@
|
|
|
+import React, { useState, useCallback, useRef } from 'react';
|
|
|
+import { useNavigate } from 'react-router-dom';
|
|
|
+import ReactFlow, {
|
|
|
+ Node,
|
|
|
+ Edge,
|
|
|
+ addEdge,
|
|
|
+ Connection,
|
|
|
+ useNodesState,
|
|
|
+ useEdgesState,
|
|
|
+ Controls,
|
|
|
+ Background,
|
|
|
+ MiniMap,
|
|
|
+ NodeTypes,
|
|
|
+ BackgroundVariant,
|
|
|
+ Handle,
|
|
|
+ Position,
|
|
|
+} from 'reactflow';
|
|
|
+import 'reactflow/dist/style.css';
|
|
|
+import {
|
|
|
+ Save,
|
|
|
+ ArrowLeft,
|
|
|
+ Undo2,
|
|
|
+ Redo2,
|
|
|
+ FileText,
|
|
|
+ CheckCircle,
|
|
|
+ ClipboardCheck,
|
|
|
+ PenTool,
|
|
|
+ Shield,
|
|
|
+ Unlock,
|
|
|
+ LockKeyhole,
|
|
|
+ CheckSquare,
|
|
|
+ X,
|
|
|
+ ZoomIn,
|
|
|
+ ZoomOut,
|
|
|
+ Wrench,
|
|
|
+} from 'lucide-react';
|
|
|
+import { Button, Input, Select, Checkbox, Tabs } from 'antd';
|
|
|
+import { toast } from 'sonner';
|
|
|
+
|
|
|
+// 节点配置
|
|
|
+const nodeConfigs = [
|
|
|
+ {
|
|
|
+ type: 'createJob',
|
|
|
+ label: '创建作业',
|
|
|
+ icon: Wrench,
|
|
|
+ color: 'bg-blue-500',
|
|
|
+ bgColor: 'bg-blue-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'confirm',
|
|
|
+ label: '确认',
|
|
|
+ icon: CheckCircle,
|
|
|
+ color: 'bg-green-500',
|
|
|
+ bgColor: 'bg-green-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'review',
|
|
|
+ label: '审核',
|
|
|
+ icon: ClipboardCheck,
|
|
|
+ color: 'bg-orange-500',
|
|
|
+ bgColor: 'bg-orange-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'inputInfo',
|
|
|
+ label: '录入信息',
|
|
|
+ icon: PenTool,
|
|
|
+ color: 'bg-purple-500',
|
|
|
+ bgColor: 'bg-purple-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'isolation',
|
|
|
+ label: '隔离/方案',
|
|
|
+ icon: Shield,
|
|
|
+ color: 'bg-red-500',
|
|
|
+ bgColor: 'bg-red-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'releaseIsolation',
|
|
|
+ label: '解除隔离',
|
|
|
+ icon: Unlock,
|
|
|
+ color: 'bg-yellow-500',
|
|
|
+ bgColor: 'bg-yellow-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'returnLock',
|
|
|
+ label: '还锁',
|
|
|
+ icon: LockKeyhole,
|
|
|
+ color: 'bg-indigo-500',
|
|
|
+ bgColor: 'bg-indigo-50',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ type: 'complete',
|
|
|
+ label: '完成/结束',
|
|
|
+ icon: CheckSquare,
|
|
|
+ color: 'bg-gray-500',
|
|
|
+ bgColor: 'bg-gray-50',
|
|
|
+ },
|
|
|
+];
|
|
|
+
|
|
|
+// 自定义节点组件
|
|
|
+function CustomNode({ data, selected, id }: any) {
|
|
|
+ const config = nodeConfigs.find(c => c.type === data.type);
|
|
|
+ const Icon = config?.icon || FileText;
|
|
|
+ // 从节点ID中提取序号,或使用data中的nodeId
|
|
|
+ const nodeId = data.nodeId || (id ? String(parseInt(id.split('-').pop() || '0') % 1000).padStart(3, '0') : '001');
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`relative px-3 py-2.5 rounded-lg shadow-sm border-2 w-auto min-w-[100px] max-w-[200px] bg-white ${
|
|
|
+ selected
|
|
|
+ ? 'border-blue-500 shadow-md ring-1 ring-blue-200'
|
|
|
+ : 'border-gray-200 hover:border-gray-300'
|
|
|
+ } transition-all`}
|
|
|
+ >
|
|
|
+ {/* 连接点 */}
|
|
|
+ <Handle
|
|
|
+ type="target"
|
|
|
+ position={Position.Top}
|
|
|
+ className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
|
|
|
+ style={{ top: -6, left: '50%', transform: 'translateX(-50%)' }}
|
|
|
+ />
|
|
|
+ <Handle
|
|
|
+ type="source"
|
|
|
+ position={Position.Bottom}
|
|
|
+ className="!w-3 !h-3 !bg-green-500 !border-2 !border-white !rounded-full !shadow-sm"
|
|
|
+ style={{ bottom: -6, left: '50%', transform: 'translateX(-50%)' }}
|
|
|
+ />
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <div className={`${config?.color || 'bg-gray-500'} p-1.5 rounded flex-shrink-0 shadow-sm`}>
|
|
|
+ <Icon className="w-4 h-4 text-white" />
|
|
|
+ </div>
|
|
|
+ <div className="flex-1 min-w-0">
|
|
|
+ <div className="font-semibold text-xs text-gray-900 mb-0.5 leading-tight whitespace-nowrap">
|
|
|
+ {data.label || config?.label}
|
|
|
+ </div>
|
|
|
+ <div className="text-[10px] text-gray-500">
|
|
|
+ ID: {nodeId}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
+// 各个节点类型
|
|
|
+function CreateJobNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function ConfirmNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function ReviewNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function InputInfoNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function IsolationNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function ReleaseIsolationNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function ReturnLockNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+function CompleteNode(props: any) {
|
|
|
+ return <CustomNode {...props} />;
|
|
|
+}
|
|
|
+
|
|
|
+// 自定义节点类型映射
|
|
|
+const nodeTypes = {
|
|
|
+ createJob: CreateJobNode,
|
|
|
+ confirm: ConfirmNode,
|
|
|
+ review: ReviewNode,
|
|
|
+ inputInfo: InputInfoNode,
|
|
|
+ isolation: IsolationNode,
|
|
|
+ releaseIsolation: ReleaseIsolationNode,
|
|
|
+ returnLock: ReturnLockNode,
|
|
|
+ complete: CompleteNode,
|
|
|
+};
|
|
|
+
|
|
|
+export default function ProcessDesigner() {
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
|
|
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
|
|
+ const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
|
|
+ const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
|
|
+ const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
|
|
|
+ const [zoom, setZoom] = useState(1);
|
|
|
+
|
|
|
+ // 节点配置状态
|
|
|
+ const [nodeConfig, setNodeConfig] = useState({
|
|
|
+ nodeName: '',
|
|
|
+ nodeIcon: '',
|
|
|
+ responsible: '',
|
|
|
+ remark: '',
|
|
|
+ submitForm: '',
|
|
|
+ notificationMethods: {
|
|
|
+ sms: false,
|
|
|
+ message: false,
|
|
|
+ email: false,
|
|
|
+ app: false,
|
|
|
+ },
|
|
|
+ notificationPerson: '',
|
|
|
+ notificationTime: '',
|
|
|
+ });
|
|
|
+
|
|
|
+ // 历史记录(用于撤销/重做)
|
|
|
+ const [history, setHistory] = useState<{ nodes: Node[]; edges: Edge[] }[]>([]);
|
|
|
+ const [historyIndex, setHistoryIndex] = useState(-1);
|
|
|
+
|
|
|
+ // 拖拽处理
|
|
|
+ const onDragStart = (event: React.DragEvent, nodeType: string) => {
|
|
|
+ event.dataTransfer.setData('application/reactflow', nodeType);
|
|
|
+ event.dataTransfer.effectAllowed = 'move';
|
|
|
+ };
|
|
|
+
|
|
|
+ // 连接处理
|
|
|
+ const onConnect = useCallback(
|
|
|
+ (params: Connection) => {
|
|
|
+ setEdges((eds) => {
|
|
|
+ const newEdges = addEdge(params, eds);
|
|
|
+ // 保存历史
|
|
|
+ const newHistory = history.slice(0, historyIndex + 1);
|
|
|
+ newHistory.push({ nodes: [...nodes], edges: [...newEdges] });
|
|
|
+ setHistory(newHistory);
|
|
|
+ setHistoryIndex(newHistory.length - 1);
|
|
|
+ return newEdges;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ [setEdges, history, historyIndex, nodes]
|
|
|
+ );
|
|
|
+
|
|
|
+ // 节点点击处理
|
|
|
+ const onNodeClick = useCallback((event: React.MouseEvent, node: Node) => {
|
|
|
+ setSelectedNode(node);
|
|
|
+ // 加载节点配置
|
|
|
+ const nodeData = node.data || {};
|
|
|
+ const config = nodeConfigs.find(c => c.type === nodeData.type);
|
|
|
+ setNodeConfig({
|
|
|
+ nodeName: nodeData.label || config?.label || '',
|
|
|
+ nodeIcon: nodeData.type || '',
|
|
|
+ responsible: nodeData.responsible || '',
|
|
|
+ remark: nodeData.remark || '',
|
|
|
+ submitForm: nodeData.submitForm || '',
|
|
|
+ notificationMethods: nodeData.notificationMethods || {
|
|
|
+ sms: false,
|
|
|
+ message: false,
|
|
|
+ email: false,
|
|
|
+ app: false,
|
|
|
+ },
|
|
|
+ notificationPerson: nodeData.notificationPerson || '',
|
|
|
+ notificationTime: nodeData.notificationTime || '',
|
|
|
+ });
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 画布点击处理(取消选择)
|
|
|
+ const onPaneClick = useCallback(() => {
|
|
|
+ setSelectedNode(null);
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 撤销
|
|
|
+ const handleUndo = useCallback(() => {
|
|
|
+ if (historyIndex > 0) {
|
|
|
+ const prevState = history[historyIndex - 1];
|
|
|
+ setNodes(prevState.nodes);
|
|
|
+ setEdges(prevState.edges);
|
|
|
+ setHistoryIndex(historyIndex - 1);
|
|
|
+ }
|
|
|
+ }, [history, historyIndex, setNodes, setEdges]);
|
|
|
+
|
|
|
+ // 重做
|
|
|
+ const handleRedo = useCallback(() => {
|
|
|
+ if (historyIndex < history.length - 1) {
|
|
|
+ const nextState = history[historyIndex + 1];
|
|
|
+ setNodes(nextState.nodes);
|
|
|
+ setEdges(nextState.edges);
|
|
|
+ setHistoryIndex(historyIndex + 1);
|
|
|
+ }
|
|
|
+ }, [history, historyIndex, setNodes, setEdges]);
|
|
|
+
|
|
|
+ // 拖放处理
|
|
|
+ const onDrop = useCallback(
|
|
|
+ (event: React.DragEvent) => {
|
|
|
+ event.preventDefault();
|
|
|
+
|
|
|
+ const type = event.dataTransfer.getData('application/reactflow');
|
|
|
+ if (!type || !reactFlowInstance) return;
|
|
|
+
|
|
|
+ const position = reactFlowInstance.screenToFlowPosition({
|
|
|
+ x: event.clientX,
|
|
|
+ y: event.clientY,
|
|
|
+ });
|
|
|
+
|
|
|
+ const config = nodeConfigs.find(c => c.type === type);
|
|
|
+ const timestamp = Date.now();
|
|
|
+ const nodeId = `${type}-${timestamp}`;
|
|
|
+ const nodeNumber = nodes.length + 1;
|
|
|
+ const newNode: Node = {
|
|
|
+ id: nodeId,
|
|
|
+ type,
|
|
|
+ position,
|
|
|
+ data: {
|
|
|
+ label: config?.label || type,
|
|
|
+ type,
|
|
|
+ nodeId: String(nodeNumber).padStart(3, '0'),
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ setNodes((nds) => {
|
|
|
+ const newNodes = nds.concat(newNode);
|
|
|
+ // 保存历史
|
|
|
+ const newHistory = history.slice(0, historyIndex + 1);
|
|
|
+ newHistory.push({ nodes: [...newNodes], edges: [...edges] });
|
|
|
+ setHistory(newHistory);
|
|
|
+ setHistoryIndex(newHistory.length - 1);
|
|
|
+ return newNodes;
|
|
|
+ });
|
|
|
+ },
|
|
|
+ [reactFlowInstance, setNodes, history, historyIndex, edges, nodes.length]
|
|
|
+ );
|
|
|
+
|
|
|
+ const onDragOver = useCallback((event: React.DragEvent) => {
|
|
|
+ event.preventDefault();
|
|
|
+ event.dataTransfer.dropEffect = 'move';
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ // 更新节点配置
|
|
|
+ const updateNodeConfig = useCallback(() => {
|
|
|
+ if (!selectedNode) return;
|
|
|
+
|
|
|
+ setNodes((nds) => {
|
|
|
+ const updatedNodes = nds.map((node) => {
|
|
|
+ if (node.id === selectedNode.id) {
|
|
|
+ return {
|
|
|
+ ...node,
|
|
|
+ data: {
|
|
|
+ ...node.data,
|
|
|
+ label: nodeConfig.nodeName,
|
|
|
+ responsible: nodeConfig.responsible,
|
|
|
+ remark: nodeConfig.remark,
|
|
|
+ submitForm: nodeConfig.submitForm,
|
|
|
+ notificationMethods: nodeConfig.notificationMethods,
|
|
|
+ notificationPerson: nodeConfig.notificationPerson,
|
|
|
+ notificationTime: nodeConfig.notificationTime,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return node;
|
|
|
+ });
|
|
|
+ // 保存历史
|
|
|
+ const newHistory = history.slice(0, historyIndex + 1);
|
|
|
+ newHistory.push({ nodes: [...updatedNodes], edges: [...edges] });
|
|
|
+ setHistory(newHistory);
|
|
|
+ setHistoryIndex(newHistory.length - 1);
|
|
|
+ return updatedNodes;
|
|
|
+ });
|
|
|
+ toast.success('节点配置已保存');
|
|
|
+ }, [selectedNode, nodeConfig, setNodes, history, historyIndex, edges]);
|
|
|
+
|
|
|
+ // 保存流程
|
|
|
+ const handleSave = () => {
|
|
|
+ console.log('保存流程:', { nodes, edges });
|
|
|
+ toast.success('流程保存成功');
|
|
|
+ };
|
|
|
+
|
|
|
+ // 返回
|
|
|
+ const handleBack = () => {
|
|
|
+ // 导航到 dashboard,Dashboard 会从 sessionStorage 读取上次的菜单状态
|
|
|
+ navigate('/dashboard');
|
|
|
+ };
|
|
|
+
|
|
|
+ // 缩放控制
|
|
|
+ const handleZoomIn = () => {
|
|
|
+ if (reactFlowInstance) {
|
|
|
+ reactFlowInstance.zoomIn();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleZoomOut = () => {
|
|
|
+ if (reactFlowInstance) {
|
|
|
+ reactFlowInstance.zoomOut();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 获取节点描述
|
|
|
+ const getNodeDescription = (type: string) => {
|
|
|
+ const descriptions: { [key: string]: string } = {
|
|
|
+ createJob: '该节点用于创建新的作业任务,是流程的起始节点。',
|
|
|
+ confirm: '该节点用于确认操作,通常需要相关人员确认后才能继续。',
|
|
|
+ review: '该节点用于审核流程,需要审核人员审批。',
|
|
|
+ inputInfo: '该节点用于录入相关信息,可以填写作业所需的各种信息。',
|
|
|
+ isolation: '该节点为作业隔离类型选择,主要包括盲板、上锁挂牌、拆除等。',
|
|
|
+ releaseIsolation: '该节点用于解除隔离状态,恢复设备正常运行。',
|
|
|
+ returnLock: '该节点用于归还锁具,完成锁具管理流程。',
|
|
|
+ complete: '该节点表示流程完成或结束,是流程的终止节点。',
|
|
|
+ };
|
|
|
+ return descriptions[type] || '该节点的功能描述。';
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="h-screen w-screen flex flex-col bg-gray-50">
|
|
|
+ {/* 顶部工具栏 */}
|
|
|
+ <div className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 shadow-sm z-10">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-lg font-bold text-blue-600 mr-2">流程设计器</span>
|
|
|
+ <div className="h-5 w-px bg-gray-300 mx-1" />
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ className="flex items-center gap-1.5"
|
|
|
+ onClick={handleSave}
|
|
|
+ >
|
|
|
+ <Save className="w-4 h-4" />
|
|
|
+ <span>保存</span>
|
|
|
+ </Button>
|
|
|
+ <div className="h-5 w-px bg-gray-300 mx-1" />
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ className="flex items-center gap-1.5"
|
|
|
+ onClick={handleBack}
|
|
|
+ >
|
|
|
+ <ArrowLeft className="w-4 h-4" />
|
|
|
+ <span>返回</span>
|
|
|
+ </Button>
|
|
|
+ <div className="h-5 w-px bg-gray-300 mx-1" />
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ className="flex items-center gap-1.5"
|
|
|
+ onClick={handleUndo}
|
|
|
+ disabled={historyIndex <= 0}
|
|
|
+ >
|
|
|
+ <Undo2 className="w-4 h-4" />
|
|
|
+ <span>撤销</span>
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ className="flex items-center gap-1.5"
|
|
|
+ onClick={handleRedo}
|
|
|
+ disabled={historyIndex >= history.length - 1}
|
|
|
+ >
|
|
|
+ <Redo2 className="w-4 h-4" />
|
|
|
+ <span>重做</span>
|
|
|
+ </Button>
|
|
|
+ <div className="h-5 w-px bg-gray-300 mx-1" />
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ icon={<ZoomOut className="w-4 h-4" />}
|
|
|
+ onClick={handleZoomOut}
|
|
|
+ />
|
|
|
+ <span className="text-sm text-gray-600 px-2 min-w-[50px] text-center">
|
|
|
+ {Math.round(zoom * 100)}%
|
|
|
+ </span>
|
|
|
+ <Button
|
|
|
+ size="small"
|
|
|
+ icon={<ZoomIn className="w-4 h-4" />}
|
|
|
+ onClick={handleZoomIn}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 主内容区 */}
|
|
|
+ <div className="flex-1 flex overflow-hidden">
|
|
|
+ {/* 左侧节点面板 */}
|
|
|
+ <div className="w-56 bg-gray-50 border-r border-gray-200 overflow-y-auto">
|
|
|
+ <div className="p-2 space-y-3">
|
|
|
+ {nodeConfigs.map((config) => {
|
|
|
+ const Icon = config.icon;
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ key={config.type}
|
|
|
+ draggable
|
|
|
+ onDragStart={(e) => onDragStart(e, config.type)}
|
|
|
+ className={`flex flex-col items-center gap-1.5 p-2 rounded-lg ${config.bgColor} border border-gray-200 hover:border-blue-400 hover:shadow-sm cursor-move transition-all`}
|
|
|
+ >
|
|
|
+ <div className={`${config.color} p-1.5 rounded-lg shadow-sm`}>
|
|
|
+ <Icon className="w-5 h-5 text-white" />
|
|
|
+ </div>
|
|
|
+ <span className="text-xs font-medium text-gray-700 text-center leading-tight">
|
|
|
+ {config.label}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 中间画布 */}
|
|
|
+ <div className="flex-1 relative bg-white" ref={reactFlowWrapper}>
|
|
|
+ <ReactFlow
|
|
|
+ nodes={nodes}
|
|
|
+ edges={edges}
|
|
|
+ onNodesChange={onNodesChange}
|
|
|
+ onEdgesChange={onEdgesChange}
|
|
|
+ onConnect={onConnect}
|
|
|
+ onNodeClick={onNodeClick}
|
|
|
+ onPaneClick={onPaneClick}
|
|
|
+ onDrop={onDrop}
|
|
|
+ onDragOver={onDragOver}
|
|
|
+ nodeTypes={nodeTypes as NodeTypes}
|
|
|
+ fitView
|
|
|
+ onInit={setReactFlowInstance}
|
|
|
+ onMove={(_, viewport) => setZoom(viewport.zoom)}
|
|
|
+ defaultEdgeOptions={{
|
|
|
+ style: { strokeWidth: 2, stroke: '#94a3b8' },
|
|
|
+ type: 'smoothstep',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <Controls className="!bg-white !border !border-gray-200 !rounded-lg !shadow-md" />
|
|
|
+ <Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#e5e7eb" />
|
|
|
+ <MiniMap
|
|
|
+ nodeColor={(node) => {
|
|
|
+ const config = nodeConfigs.find(c => c.type === node.type);
|
|
|
+ if (config?.color === 'bg-blue-500') return '#3b82f6';
|
|
|
+ if (config?.color === 'bg-green-500') return '#10b981';
|
|
|
+ if (config?.color === 'bg-orange-500') return '#f97316';
|
|
|
+ if (config?.color === 'bg-red-500') return '#ef4444';
|
|
|
+ if (config?.color === 'bg-yellow-500') return '#eab308';
|
|
|
+ if (config?.color === 'bg-indigo-500') return '#6366f1';
|
|
|
+ if (config?.color === 'bg-purple-500') return '#a855f7';
|
|
|
+ return '#6b7280';
|
|
|
+ }}
|
|
|
+ maskColor="rgba(0, 0, 0, 0.05)"
|
|
|
+ className="!bg-white !border !border-gray-200 !rounded-lg"
|
|
|
+ />
|
|
|
+ </ReactFlow>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 右侧属性面板 */}
|
|
|
+ <div className="w-80 bg-gray-50 border-l border-gray-200 overflow-y-auto">
|
|
|
+ {selectedNode ? (
|
|
|
+ <div className="h-full flex flex-col">
|
|
|
+ {/* 头部 */}
|
|
|
+ <div className="p-4 bg-white border-b border-gray-200 flex items-center justify-between sticky top-0 z-10 shadow-sm">
|
|
|
+ <h3 className="text-base font-semibold text-gray-900">
|
|
|
+ {nodeConfig.nodeName || selectedNode.data?.label || '节点'} 设置
|
|
|
+ </h3>
|
|
|
+ <button
|
|
|
+ onClick={() => setSelectedNode(null)}
|
|
|
+ className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
|
|
+ >
|
|
|
+ <X className="w-4 h-4 text-gray-500" />
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 内容 */}
|
|
|
+ <div className="flex-1 p-4 overflow-y-auto">
|
|
|
+ <Tabs
|
|
|
+ defaultActiveKey="info"
|
|
|
+ className="[&_.ant-tabs-tab]:!px-4 [&_.ant-tabs-tab]:!py-2.5 [&_.ant-tabs-tab-active]:!text-blue-600 [&_.ant-tabs-tab-active]:!font-semibold [&_.ant-tabs-ink-bar]:!bg-blue-600 [&_.ant-tabs-tab]:!text-gray-600 [&_.ant-tabs-tab]:!border-b-2 [&_.ant-tabs-tab]:!border-transparent [&_.ant-tabs-tab:hover]:!text-blue-500"
|
|
|
+ items={[
|
|
|
+ {
|
|
|
+ key: 'info',
|
|
|
+ label: '节点信息',
|
|
|
+ children: (
|
|
|
+ <div className="space-y-5">
|
|
|
+ {/* 描述 */}
|
|
|
+ <div className="text-sm text-gray-600 leading-relaxed bg-gray-50 p-3 rounded-lg">
|
|
|
+ {getNodeDescription(selectedNode.data?.type || '')}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 节点名称 */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 节点名称 <span className="text-red-500">*</span>
|
|
|
+ </label>
|
|
|
+ <p className="text-xs text-gray-500 mb-2.5 leading-relaxed">
|
|
|
+ (默认名称: {nodeConfigs.find(c => c.type === selectedNode.data?.type)?.label || '节点名称'},可根据需求调整)
|
|
|
+ </p>
|
|
|
+ <Input
|
|
|
+ value={nodeConfig.nodeName}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, nodeName: e.target.value })
|
|
|
+ }
|
|
|
+ placeholder="请输入节点名称"
|
|
|
+ className="rounded-lg border-gray-200 h-10"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 显示图标 */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 显示图标
|
|
|
+ </label>
|
|
|
+ <div className="border border-gray-200 rounded-lg p-3 bg-white flex items-center gap-3 cursor-pointer hover:border-blue-400 transition-colors shadow-sm">
|
|
|
+ {(() => {
|
|
|
+ const iconType = nodeConfig.nodeIcon || selectedNode.data?.type;
|
|
|
+ const config = nodeConfigs.find(c => c.type === iconType);
|
|
|
+ const Icon = config?.icon || FileText;
|
|
|
+ return (
|
|
|
+ <>
|
|
|
+ <div className={`${config?.color || 'bg-gray-500'} p-2 rounded-lg shadow-sm`}>
|
|
|
+ <Icon className="w-5 h-5 text-white" />
|
|
|
+ </div>
|
|
|
+ <span className="text-sm text-gray-600">选择图标</span>
|
|
|
+ </>
|
|
|
+ );
|
|
|
+ })()}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 负责人 */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 负责人
|
|
|
+ </label>
|
|
|
+ <Select
|
|
|
+ value={nodeConfig.responsible}
|
|
|
+ onChange={(value) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, responsible: value })
|
|
|
+ }
|
|
|
+ placeholder="请选择负责人"
|
|
|
+ className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
|
|
|
+ allowClear
|
|
|
+ >
|
|
|
+ <Select.Option value="user1">张三</Select.Option>
|
|
|
+ <Select.Option value="user2">李四</Select.Option>
|
|
|
+ <Select.Option value="user3">王五</Select.Option>
|
|
|
+ </Select>
|
|
|
+ <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">
|
|
|
+ 对该任务或步骤节点进行处理的人员,若不选则需要在创建作业时进行选择
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 备注 */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 备注
|
|
|
+ </label>
|
|
|
+ <Input.TextArea
|
|
|
+ value={nodeConfig.remark}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, remark: e.target.value })
|
|
|
+ }
|
|
|
+ placeholder="请输入备注"
|
|
|
+ rows={3}
|
|
|
+ className="rounded-lg border-gray-200"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ onClick={updateNodeConfig}
|
|
|
+ className="w-full rounded-lg"
|
|
|
+ size="large"
|
|
|
+ >
|
|
|
+ 保存配置
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'form',
|
|
|
+ label: '提交表单',
|
|
|
+ children: (
|
|
|
+ <div className="space-y-5">
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 提交表单 <span className="text-red-500">*</span>
|
|
|
+ </label>
|
|
|
+ <Select
|
|
|
+ value={nodeConfig.submitForm}
|
|
|
+ onChange={(value) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, submitForm: value })
|
|
|
+ }
|
|
|
+ placeholder="请选择"
|
|
|
+ className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
|
|
|
+ allowClear
|
|
|
+ >
|
|
|
+ <Select.Option value="form1">表单1</Select.Option>
|
|
|
+ <Select.Option value="form2">表单2</Select.Option>
|
|
|
+ <Select.Option value="form3">表单3</Select.Option>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ onClick={updateNodeConfig}
|
|
|
+ className="w-full rounded-lg"
|
|
|
+ size="large"
|
|
|
+ >
|
|
|
+ 保存配置
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ key: 'notification',
|
|
|
+ label: '通知消息',
|
|
|
+ children: (
|
|
|
+ <div className="space-y-5">
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-3">
|
|
|
+ 通知方式
|
|
|
+ </label>
|
|
|
+ <div className="space-y-3 bg-gray-50 p-3 rounded-lg">
|
|
|
+ <Checkbox
|
|
|
+ checked={nodeConfig.notificationMethods.sms}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({
|
|
|
+ ...nodeConfig,
|
|
|
+ notificationMethods: {
|
|
|
+ ...nodeConfig.notificationMethods,
|
|
|
+ sms: e.target.checked,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ 短信
|
|
|
+ </Checkbox>
|
|
|
+ <div>
|
|
|
+ <Checkbox
|
|
|
+ checked={nodeConfig.notificationMethods.message}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({
|
|
|
+ ...nodeConfig,
|
|
|
+ notificationMethods: {
|
|
|
+ ...nodeConfig.notificationMethods,
|
|
|
+ message: e.target.checked,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ 站内信
|
|
|
+ </Checkbox>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <Checkbox
|
|
|
+ checked={nodeConfig.notificationMethods.email}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({
|
|
|
+ ...nodeConfig,
|
|
|
+ notificationMethods: {
|
|
|
+ ...nodeConfig.notificationMethods,
|
|
|
+ email: e.target.checked,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ 邮件
|
|
|
+ </Checkbox>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <Checkbox
|
|
|
+ checked={nodeConfig.notificationMethods.app}
|
|
|
+ onChange={(e) =>
|
|
|
+ setNodeConfig({
|
|
|
+ ...nodeConfig,
|
|
|
+ notificationMethods: {
|
|
|
+ ...nodeConfig.notificationMethods,
|
|
|
+ app: e.target.checked,
|
|
|
+ },
|
|
|
+ })
|
|
|
+ }
|
|
|
+ >
|
|
|
+ APP通知
|
|
|
+ </Checkbox>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 通知人
|
|
|
+ </label>
|
|
|
+ <Select
|
|
|
+ value={nodeConfig.notificationPerson}
|
|
|
+ onChange={(value) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, notificationPerson: value })
|
|
|
+ }
|
|
|
+ placeholder="请选择通知人"
|
|
|
+ className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
|
|
|
+ allowClear
|
|
|
+ >
|
|
|
+ <Select.Option value="user1">张三</Select.Option>
|
|
|
+ <Select.Option value="user2">李四</Select.Option>
|
|
|
+ <Select.Option value="user3">王五</Select.Option>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-2">
|
|
|
+ 通知时间
|
|
|
+ </label>
|
|
|
+ <Select
|
|
|
+ value={nodeConfig.notificationTime}
|
|
|
+ onChange={(value) =>
|
|
|
+ setNodeConfig({ ...nodeConfig, notificationTime: value })
|
|
|
+ }
|
|
|
+ placeholder="请选择通知时间"
|
|
|
+ className="w-full [&_.ant-select-selector]:!rounded-lg [&_.ant-select-selector]:!h-10"
|
|
|
+ allowClear
|
|
|
+ >
|
|
|
+ <Select.Option value="before">执行前(上一个节点结束后)</Select.Option>
|
|
|
+ <Select.Option value="after">执行后(该节点结束后)</Select.Option>
|
|
|
+ <Select.Option value="time">选择时间</Select.Option>
|
|
|
+ <Select.Option value="30min">任务开始前30分钟</Select.Option>
|
|
|
+ <Select.Option value="1h">任务开始前1小时</Select.Option>
|
|
|
+ <Select.Option value="2h">任务开始前2小时</Select.Option>
|
|
|
+ <Select.Option value="4h">任务开始前4小时</Select.Option>
|
|
|
+ <Select.Option value="5h">任务开始前5小时</Select.Option>
|
|
|
+ <Select.Option value="8h">任务开始前8小时</Select.Option>
|
|
|
+ <Select.Option value="12h">任务开始前12小时</Select.Option>
|
|
|
+ <Select.Option value="24h">任务开始前24小时</Select.Option>
|
|
|
+ <Select.Option value="48h">任务开始前48小时</Select.Option>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ onClick={updateNodeConfig}
|
|
|
+ className="w-full rounded-lg"
|
|
|
+ size="large"
|
|
|
+ >
|
|
|
+ 保存配置
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ ]}
|
|
|
+ className="[&_.ant-tabs-tab]:!px-4 [&_.ant-tabs-tab]:!py-2 [&_.ant-tabs-tab-active]:!text-blue-600 [&_.ant-tabs-ink-bar]:!bg-blue-600"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="p-4 text-center text-gray-500 mt-20">
|
|
|
+ <p className="text-sm">请点击画布中的节点查看和编辑属性</p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|