flowable-devops/backend/docs/03-前端技术设计.md
dengqichen d42166d2c0 提交
2025-10-13 16:25:13 +08:00

34 KiB
Raw Permalink Blame History

前端技术设计文档

版本: v1.0
关联: 01-架构总览.md


一、技术栈详细说明

1.1 核心依赖

{
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "typescript": "^5.0.0",
    
    "reactflow": "^11.10.0",
    "antd": "^5.12.0",
    "@ant-design/icons": "^5.2.6",
    
    "zustand": "^4.4.7",
    "axios": "^1.6.2",
    
    "@monaco-editor/react": "^4.6.0",
    
    "dayjs": "^1.11.10"
  },
  "devDependencies": {
    "@vitejs/plugin-react": "^4.2.0",
    "vite": "^5.0.0",
    
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    
    "eslint": "^8.55.0",
    "prettier": "^3.1.1"
  }
}

1.2 项目结构

frontend/
├── src/
│   ├── api/                    # API调用
│   │   ├── workflow.ts         # 工作流API
│   │   ├── nodeType.ts         # 节点类型API
│   │   └── task.ts             # 审批任务API
│   │
│   ├── components/             # 通用组件
│   │   ├── WorkflowEditor/     # 工作流编辑器(核心)⭐
│   │   │   ├── index.tsx
│   │   │   ├── Canvas.tsx      # ReactFlow画布
│   │   │   ├── NodePalette.tsx # 节点面板
│   │   │   ├── CustomNode.tsx  # 自定义节点样式
│   │   │   └── MiniMap.tsx     # 缩略图
│   │   │
│   │   ├── NodeConfigPanel/    # 节点配置面板(核心)⭐⭐
│   │   │   ├── index.tsx
│   │   │   ├── FieldMappingSelector.tsx  # 字段映射选择器⭐⭐⭐
│   │   │   ├── ExpressionInput.tsx       # 表达式输入框
│   │   │   ├── CodeEditor.tsx            # 代码编辑器
│   │   │   └── DynamicForm.tsx           # 动态表单
│   │   │
│   │   ├── ExecutionViewer/    # 执行查看器
│   │   │   ├── index.tsx
│   │   │   ├── LogPanel.tsx    # 日志面板
│   │   │   └── NodeStatus.tsx  # 节点状态
│   │   │
│   │   └── ApprovalCenter/     # 审批中心
│   │       ├── index.tsx
│   │       ├── TaskList.tsx    # 任务列表
│   │       └── ApprovalForm.tsx # 审批表单
│   │
│   ├── pages/                  # 页面
│   │   ├── WorkflowListPage.tsx      # 工作流列表
│   │   ├── WorkflowEditorPage.tsx    # 工作流编辑
│   │   ├── ExecutionHistoryPage.tsx  # 执行历史
│   │   └── ApprovalCenterPage.tsx    # 审批中心
│   │
│   ├── store/                  # 状态管理Zustand
│   │   ├── workflowStore.ts    # 工作流状态
│   │   ├── nodeTypeStore.ts    # 节点类型
│   │   └── executionStore.ts   # 执行状态
│   │
│   ├── types/                  # TypeScript类型定义
│   │   ├── workflow.ts
│   │   ├── node.ts
│   │   └── execution.ts
│   │
│   ├── utils/                  # 工具函数
│   │   ├── expressionParser.ts # 表达式解析
│   │   ├── schemaBuilder.ts    # Schema构建
│   │   └── validation.ts       # 表单验证
│   │
│   ├── App.tsx
│   ├── main.tsx
│   └── index.css
│
├── package.json
├── tsconfig.json
├── vite.config.ts
└── README.md

二、核心组件实现

2.1 工作流编辑器WorkflowEditor

Canvas.tsx - ReactFlow 画布

import React, { useCallback, useRef } from 'react';
import ReactFlow, {
  Node,
  Edge,
  Controls,
  Background,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
  Connection,
  NodeTypes,
  BackgroundVariant,
} from 'reactflow';
import 'reactflow/dist/style.css';

import CustomNode from './CustomNode';
import { useWorkflowStore } from '@/store/workflowStore';

// 自定义节点类型
const nodeTypes: NodeTypes = {
  custom: CustomNode,
};

interface Props {
  onNodeClick: (node: Node) => void;
}

export default function Canvas({ onNodeClick }: Props) {
  const reactFlowWrapper = useRef<HTMLDivElement>(null);
  
  // 从store获取状态
  const { nodes, edges, setNodes, setEdges } = useWorkflowStore();
  
  // ReactFlow hooks
  const [rfNodes, setRfNodes, onNodesChange] = useNodesState(nodes);
  const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState(edges);
  
  /**
   * 连线处理(重要)⭐
   * 
   * 验证规则:
   * 1. 不能连接到自己
   * 2. 两个节点间只能有一条连线
   * 3. 不能形成环(可选)
   */
  const onConnect = useCallback(
    (connection: Connection) => {
      // 验证:不能连接到自己
      if (connection.source === connection.target) {
        message.warning('节点不能连接到自己');
        return;
      }
      
      // 验证:检查是否已存在连线
      const existingEdge = rfEdges.find(
        (edge) =>
          edge.source === connection.source &&
          edge.target === connection.target
      );
      
      if (existingEdge) {
        message.warning('这两个节点已经连接');
        return;
      }
      
      // 添加连线
      const newEdge = {
        ...connection,
        id: `edge-${connection.source}-${connection.target}`,
        type: 'smoothstep',
        animated: true,
      };
      
      setRfEdges((eds) => addEdge(newEdge, eds));
      setEdges(addEdge(newEdge, edges));
    },
    [rfEdges, edges, setEdges, setRfEdges]
  );
  
  /**
   * 节点拖拽结束(更新位置)
   */
  const onNodeDragStop = useCallback(
    (event: React.MouseEvent, node: Node) => {
      setNodes(
        nodes.map((n) =>
          n.id === node.id
            ? { ...n, position: node.position }
            : n
        )
      );
    },
    [nodes, setNodes]
  );
  
  /**
   * 从节点面板拖拽到画布(重要)⭐⭐
   */
  const onDrop = useCallback(
    (event: React.DragEvent) => {
      event.preventDefault();
      
      const reactFlowBounds = reactFlowWrapper.current?.getBoundingClientRect();
      const nodeTypeData = event.dataTransfer.getData('application/reactflow');
      
      if (!nodeTypeData || !reactFlowBounds) {
        return;
      }
      
      const nodeType = JSON.parse(nodeTypeData);
      
      // 计算放置位置
      const position = {
        x: event.clientX - reactFlowBounds.left,
        y: event.clientY - reactFlowBounds.top,
      };
      
      // 创建新节点
      const newNode: Node = {
        id: `node-${Date.now()}`,
        type: 'custom',
        position,
        data: {
          type: nodeType.id,
          name: nodeType.displayName,
          icon: nodeType.icon,
          config: {},  // 空配置,等待用户填写
        },
      };
      
      setRfNodes((nds) => nds.concat(newNode));
      setNodes([...nodes, newNode]);
    },
    [nodes, setNodes, setRfNodes]
  );
  
  const onDragOver = useCallback((event: React.DragEvent) => {
    event.preventDefault();
    event.dataTransfer.dropEffect = 'move';
  }, []);
  
  return (
    <div
      ref={reactFlowWrapper}
      style={{ width: '100%', height: '100%' }}
      onDrop={onDrop}
      onDragOver={onDragOver}
    >
      <ReactFlow
        nodes={rfNodes}
        edges={rfEdges}
        nodeTypes={nodeTypes}
        onNodesChange={onNodesChange}
        onEdgesChange={onEdgesChange}
        onConnect={onConnect}
        onNodeClick={(_, node) => onNodeClick(node)}
        onNodeDragStop={onNodeDragStop}
        fitView
        snapToGrid
        snapGrid={[15, 15]}
      >
        <Controls />
        <MiniMap
          nodeStrokeWidth={3}
          zoomable
          pannable
        />
        <Background variant={BackgroundVariant.Dots} gap={12} size={1} />
      </ReactFlow>
    </div>
  );
}

CustomNode.tsx - 自定义节点样式

import React, { memo } from 'react';
import { Handle, Position, NodeProps } from 'reactflow';
import { Card, Tag, Typography } from 'antd';
import * as Icons from '@ant-design/icons';

const { Text } = Typography;

/**
 * 自定义节点组件
 * 
 * 功能:
 * 1. 显示节点图标和名称
 * 2. 显示节点状态(执行中、成功、失败)
 * 3. 连接点(上下左右)
 */
function CustomNode({ data, selected }: NodeProps) {
  // 动态获取图标
  const IconComponent = (Icons as any)[data.icon] || Icons.ApiOutlined;
  
  // 节点状态颜色
  const getStatusColor = () => {
    switch (data.status) {
      case 'running':
        return '#1890ff';
      case 'success':
        return '#52c41a';
      case 'failed':
        return '#ff4d4f';
      default:
        return '#d9d9d9';
    }
  };
  
  return (
    <div
      style={{
        borderRadius: 8,
        border: `2px solid ${selected ? '#1890ff' : getStatusColor()}`,
        background: '#fff',
        minWidth: 180,
        boxShadow: selected ? '0 4px 12px rgba(24,144,255,0.3)' : '0 2px 8px rgba(0,0,0,0.1)',
      }}
    >
      {/* 连接点 - 上 */}
      <Handle
        type="target"
        position={Position.Top}
        style={{ background: '#555' }}
      />
      
      {/* 节点内容 */}
      <div style={{ padding: '12px 16px' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4 }}>
          <IconComponent style={{ fontSize: 18, color: '#1890ff' }} />
          <Text strong>{data.name}</Text>
        </div>
        
        <Text type="secondary" style={{ fontSize: 12 }}>
          {data.type}
        </Text>
        
        {/* 状态标签 */}
        {data.status && (
          <div style={{ marginTop: 8 }}>
            <Tag color={getStatusColor()}>
              {data.status === 'running' && '执行中'}
              {data.status === 'success' && '成功'}
              {data.status === 'failed' && '失败'}
            </Tag>
          </div>
        )}
      </div>
      
      {/* 连接点 - 下 */}
      <Handle
        type="source"
        position={Position.Bottom}
        style={{ background: '#555' }}
      />
    </div>
  );
}

export default memo(CustomNode);

NodePalette.tsx - 节点面板

import React, { useEffect } from 'react';
import { Card, Collapse, Space, Typography, Empty, Spin } from 'antd';
import * as Icons from '@ant-design/icons';
import { useNodeTypeStore } from '@/store/nodeTypeStore';

const { Panel } = Collapse;
const { Text } = Typography;

/**
 * 节点面板
 * 
 * 功能:
 * 1. 显示所有可用节点类型
 * 2. 按分类分组
 * 3. 支持拖拽到画布
 */
export default function NodePalette() {
  const { nodeTypes, loading, fetchNodeTypes } = useNodeTypeStore();
  
  useEffect(() => {
    fetchNodeTypes();
  }, [fetchNodeTypes]);
  
  /**
   * 开始拖拽(重要)⭐
   */
  const onDragStart = (event: React.DragEvent, nodeType: NodeTypeMetadata) => {
    event.dataTransfer.setData(
      'application/reactflow',
      JSON.stringify(nodeType)
    );
    event.dataTransfer.effectAllowed = 'move';
  };
  
  // 按分类分组
  const groupedNodeTypes = nodeTypes.reduce((acc, nodeType) => {
    const category = nodeType.category || 'other';
    if (!acc[category]) {
      acc[category] = [];
    }
    acc[category].push(nodeType);
    return acc;
  }, {} as Record<string, NodeTypeMetadata[]>);
  
  // 分类显示名称
  const categoryNames: Record<string, string> = {
    api: 'API',
    database: '数据库',
    logic: '逻辑控制',
    notification: '通知',
    transform: '数据转换',
    other: '其他',
  };
  
  if (loading) {
    return (
      <div style={{ padding: 20, textAlign: 'center' }}>
        <Spin />
      </div>
    );
  }
  
  return (
    <div style={{ height: '100%', overflowY: 'auto', padding: 16 }}>
      <Typography.Title level={5} style={{ marginBottom: 16 }}>
        节点库
      </Typography.Title>
      
      {Object.keys(groupedNodeTypes).length === 0 ? (
        <Empty description="暂无可用节点" />
      ) : (
        <Collapse defaultActiveKey={Object.keys(groupedNodeTypes)} ghost>
          {Object.entries(groupedNodeTypes).map(([category, nodes]) => (
            <Panel
              key={category}
              header={categoryNames[category] || category}
            >
              <Space direction="vertical" size="small" style={{ width: '100%' }}>
                {nodes.map((nodeType) => {
                  const IconComponent = (Icons as any)[nodeType.icon] || Icons.ApiOutlined;
                  
                  return (
                    <Card
                      key={nodeType.id}
                      size="small"
                      hoverable
                      draggable
                      onDragStart={(e) => onDragStart(e, nodeType)}
                      style={{
                        cursor: 'grab',
                        borderRadius: 8,
                      }}
                    >
                      <Space>
                        <IconComponent style={{ fontSize: 16, color: '#1890ff' }} />
                        <div>
                          <Text strong style={{ fontSize: 13 }}>
                            {nodeType.displayName}
                          </Text>
                          <br />
                          <Text type="secondary" style={{ fontSize: 11 }}>
                            {nodeType.description}
                          </Text>
                        </div>
                      </Space>
                    </Card>
                  );
                })}
              </Space>
            </Panel>
          ))}
        </Collapse>
      )}
    </div>
  );
}

2.2 节点配置面板(最重要)

index.tsx - 主组件

import React, { useMemo } from 'react';
import { Form, Input, Select, Button, Space, Typography, Divider } from 'antd';
import { Node, Edge } from 'reactflow';
import { NodeTypeMetadata, FieldDefinition } from '@/types/node';
import FieldMappingSelector from './FieldMappingSelector';
import ExpressionInput from './ExpressionInput';
import CodeEditor from './CodeEditor';

const { Title, Text } = Typography;

interface Props {
  node: Node | null;
  nodes: Node[];
  edges: Edge[];
  nodeTypes: Record<string, NodeTypeMetadata>;
  onConfigChange: (nodeId: string, config: any) => void;
  onClose: () => void;
}

/**
 * 节点配置面板
 * 
 * 核心职责:
 * 1. 根据节点类型动态生成表单
 * 2. 提供字段映射选择器(从上游节点选择输出字段)⭐⭐⭐
 * 3. 支持表达式输入
 * 4. 实时保存配置
 */
export default function NodeConfigPanel({
  node,
  nodes,
  edges,
  nodeTypes,
  onConfigChange,
  onClose,
}: Props) {
  const [form] = Form.useForm();
  
  if (!node) {
    return (
      <div style={{ padding: 20, textAlign: 'center' }}>
        <Text type="secondary">请选择一个节点</Text>
      </div>
    );
  }
  
  // 获取节点类型元数据
  const nodeMetadata = nodeTypes[node.data.type];
  
  if (!nodeMetadata) {
    return <div>节点类型未找到: {node.data.type}</div>;
  }
  
  // 计算上游节点(重要)⭐⭐
  const upstreamNodes = useMemo(() => {
    // 找到所有指向当前节点的边
    const incomingEdges = edges.filter((edge) => edge.target === node.id);
    
    // 获取上游节点的详细信息
    return incomingEdges
      .map((edge) => nodes.find((n) => n.id === edge.source))
      .filter((n): n is Node => n !== undefined);
  }, [node.id, nodes, edges]);
  
  /**
   * 表单值变化时保存
   */
  const handleValuesChange = (changedValues: any, allValues: any) => {
    onConfigChange(node.id, allValues);
  };
  
  /**
   * 根据字段类型渲染输入组件(关键)⭐⭐⭐
   */
  const renderFieldInput = (field: FieldDefinition) => {
    // 1. 字段映射选择器(最核心)⭐⭐⭐
    if (field.supportsFieldMapping) {
      return (
        <FieldMappingSelector
          field={field}
          upstreamNodes={upstreamNodes}
          nodeTypes={nodeTypes}
          placeholder={`选择字段或输入表达式`}
        />
      );
    }
    
    // 2. 表达式输入框
    if (field.supportsExpression) {
      return (
        <ExpressionInput
          field={field}
          upstreamNodes={upstreamNodes}
          nodeTypes={nodeTypes}
        />
      );
    }
    
    // 3. 代码编辑器
    if (field.type === 'code') {
      return (
        <CodeEditor
          language={field.language || 'json'}
          height="200px"
        />
      );
    }
    
    // 4. 下拉选择
    if (field.type === 'select') {
      return (
        <Select placeholder={`请选择${field.label}`}>
          {field.options?.map((opt) => (
            <Select.Option key={opt} value={opt}>
              {opt}
            </Select.Option>
          ))}
        </Select>
      );
    }
    
    // 5. 数字输入
    if (field.type === 'number') {
      return (
        <Input
          type="number"
          placeholder={field.placeholder}
        />
      );
    }
    
    // 6. 多行文本
    if (field.type === 'textarea') {
      return (
        <Input.TextArea
          rows={4}
          placeholder={field.placeholder}
        />
      );
    }
    
    // 7. 默认:单行文本
    return (
      <Input placeholder={field.placeholder} />
    );
  };
  
  return (
    <div style={{ height: '100%', overflowY: 'auto', padding: 20 }}>
      {/* 标题 */}
      <div style={{ marginBottom: 20 }}>
        <Title level={4}>{node.data.name}</Title>
        <Text type="secondary">{nodeMetadata.description}</Text>
      </div>
      
      <Divider />
      
      {/* 动态表单 */}
      <Form
        form={form}
        layout="vertical"
        initialValues={node.data.config}
        onValuesChange={handleValuesChange}
      >
        {nodeMetadata.fields.map((field) => (
          <Form.Item
            key={field.name}
            name={field.name}
            label={
              <Space>
                <span>{field.label}</span>
                {field.required && <Text type="danger">*</Text>}
                {field.supportsExpression && (
                  <Text type="secondary" style={{ fontSize: 11 }}>
                    (支持表达式)
                  </Text>
                )}
              </Space>
            }
            rules={[
              {
                required: field.required,
                message: `请输入${field.label}`,
              },
            ]}
            tooltip={field.description}
          >
            {renderFieldInput(field)}
          </Form.Item>
        ))}
      </Form>
      
      {/* 上游节点信息 */}
      {upstreamNodes.length > 0 && (
        <div style={{ marginTop: 20 }}>
          <Text type="secondary" style={{ fontSize: 12 }}>
            可引用的上游节点:
          </Text>
          <div style={{ marginTop: 8 }}>
            {upstreamNodes.map((upstream) => (
              <Tag key={upstream.id} color="blue" style={{ marginBottom: 4 }}>
                {upstream.data.name}
              </Tag>
            ))}
          </div>
        </div>
      )}
      
      {/* 底部操作 */}
      <div style={{ marginTop: 20 }}>
        <Space>
          <Button onClick={onClose}>关闭</Button>
        </Space>
      </div>
    </div>
  );
}

FieldMappingSelector.tsx - 字段映射选择器(核心)

import React, { useState, useMemo } from 'react';
import { TreeSelect, Input, Space, Button, Tag, Tooltip } from 'antd';
import { FunctionOutlined, EditOutlined } from '@ant-design/icons';
import { Node } from 'reactflow';
import { NodeTypeMetadata, FieldDefinition } from '@/types/node';

interface Props {
  field: FieldDefinition;
  upstreamNodes: Node[];
  nodeTypes: Record<string, NodeTypeMetadata>;
  value?: string;
  onChange?: (value: string) => void;
  placeholder?: string;
}

/**
 * 字段映射选择器(最核心的组件)⭐⭐⭐
 * 
 * 功能:
 * 1. 显示上游节点的输出字段树形结构
 * 2. 用户可以选择字段,自动生成表达式
 * 3. 也可以手动输入表达式
 * 
 * 示例:
 * 用户选择nodes.httpRequest.output.body.email
 * 生成表达式:${nodes.httpRequest.output.body.email}
 */
export default function FieldMappingSelector({
  field,
  upstreamNodes,
  nodeTypes,
  value,
  onChange,
  placeholder,
}: Props) {
  const [mode, setMode] = useState<'select' | 'expression'>('select');
  
  /**
   * 构建字段树(核心逻辑)⭐⭐⭐
   * 
   * 根据上游节点的outputSchema构建树形选择结构
   */
  const fieldTree = useMemo(() => {
    return upstreamNodes.map((node) => {
      const nodeType = nodeTypes[node.data.type];
      
      if (!nodeType?.outputSchema) {
        return null;
      }
      
      return {
        title: (
          <Space>
            <Tag color="blue" style={{ fontSize: 11 }}>
              {node.data.name || node.id}
            </Tag>
            <span style={{ color: '#999', fontSize: 12 }}>
              {nodeType.displayName}
            </span>
          </Space>
        ),
        value: `nodes.${node.id}`,
        selectable: false,
        children: buildFieldTree(
          nodeType.outputSchema.properties || {},
          `nodes.${node.id}.output`,
          0
        ),
      };
    }).filter(Boolean);
  }, [upstreamNodes, nodeTypes]);
  
  // 选择模式
  if (mode === 'select') {
    return (
      <Space.Compact style={{ width: '100%' }}>
        <TreeSelect
          style={{ width: '100%' }}
          value={value?.replace(/^\$\{|\}$/g, '')}  // 移除 ${}
          onChange={(val) => onChange?.(`\${${val}}`)}  // 添加 ${}
          treeData={fieldTree}
          placeholder={placeholder || '选择上游节点的字段'}
          showSearch
          treeDefaultExpandAll
          dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
          treeLine={{ showLeafIcon: false }}
          filterTreeNode={(input, treeNode) => {
            return (treeNode.title as string)
              .toLowerCase()
              .includes(input.toLowerCase());
          }}
        />
        <Tooltip title="切换到表达式模式">
          <Button
            icon={<FunctionOutlined />}
            onClick={() => setMode('expression')}
          />
        </Tooltip>
      </Space.Compact>
    );
  }
  
  // 表达式模式
  return (
    <Space.Compact style={{ width: '100%' }}>
      <Input
        value={value}
        onChange={(e) => onChange?.(e.target.value)}
        placeholder="输入表达式,例如: ${nodes.node1.output.body.email}"
        prefix="${"
        suffix="}"
      />
      <Tooltip title="切换到选择模式">
        <Button
          icon={<EditOutlined />}
          onClick={() => setMode('select')}
        />
      </Tooltip>
    </Space.Compact>
  );
}

/**
 * 递归构建字段树(核心函数)⭐⭐⭐
 * 
 * @param properties JSON Schema properties
 * @param prefix 字段路径前缀
 * @param depth 当前深度(防止无限递归)
 */
function buildFieldTree(
  properties: Record<string, any>,
  prefix: string,
  depth: number
): any[] {
  // 限制最大深度为3层
  if (!properties || depth > 3) {
    return [];
  }
  
  return Object.entries(properties).map(([key, prop]) => {
    const fieldPath = `${prefix}.${key}`;
    const fieldType = prop.type || 'any';
    
    // 对象类型,递归展开
    if (fieldType === 'object' && prop.properties) {
      return {
        title: (
          <Space>
            <span>{key}</span>
            <Tag color="orange" style={{ fontSize: 10 }}>object</Tag>
          </Space>
        ),
        value: fieldPath,
        selectable: true,
        children: buildFieldTree(prop.properties, fieldPath, depth + 1),
      };
    }
    
    // 数组类型
    if (fieldType === 'array') {
      return {
        title: (
          <Space>
            <span>{key}</span>
            <Tag color="purple" style={{ fontSize: 10 }}>array</Tag>
          </Space>
        ),
        value: fieldPath,
        selectable: true,
        children: prop.items?.properties
          ? buildFieldTree(prop.items.properties, `${fieldPath}[0]`, depth + 1)
          : [],
      };
    }
    
    // 基本类型
    return {
      title: (
        <Space>
          <span>{key}</span>
          <Tag color="green" style={{ fontSize: 10 }}>{fieldType}</Tag>
        </Space>
      ),
      value: fieldPath,
      selectable: true,
    };
  });
}

2.3 状态管理Zustand

workflowStore.ts

import { create } from 'zustand';
import { Node, Edge } from 'reactflow';
import { WorkflowDefinition } from '@/types/workflow';

interface WorkflowStore {
  // 当前工作流
  currentWorkflow: WorkflowDefinition | null;
  
  // 画布状态
  nodes: Node[];
  edges: Edge[];
  
  // 选中的节点
  selectedNode: Node | null;
  
  // Actions
  setCurrentWorkflow: (workflow: WorkflowDefinition) => void;
  setNodes: (nodes: Node[]) => void;
  setEdges: (edges: Edge[]) => void;
  setSelectedNode: (node: Node | null) => void;
  
  // 添加节点
  addNode: (node: Node) => void;
  
  // 删除节点
  deleteNode: (nodeId: string) => void;
  
  // 更新节点配置
  updateNodeConfig: (nodeId: string, config: any) => void;
  
  // 保存工作流
  saveWorkflow: () => Promise<void>;
  
  // 加载工作流
  loadWorkflow: (id: string) => Promise<void>;
}

export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
  currentWorkflow: null,
  nodes: [],
  edges: [],
  selectedNode: null,
  
  setCurrentWorkflow: (workflow) => set({ currentWorkflow: workflow }),
  setNodes: (nodes) => set({ nodes }),
  setEdges: (edges) => set({ edges }),
  setSelectedNode: (node) => set({ selectedNode: node }),
  
  addNode: (node) => set((state) => ({
    nodes: [...state.nodes, node],
  })),
  
  deleteNode: (nodeId) => set((state) => ({
    nodes: state.nodes.filter((n) => n.id !== nodeId),
    edges: state.edges.filter(
      (e) => e.source !== nodeId && e.target !== nodeId
    ),
  })),
  
  updateNodeConfig: (nodeId, config) => set((state) => ({
    nodes: state.nodes.map((n) =>
      n.id === nodeId
        ? { ...n, data: { ...n.data, config } }
        : n
    ),
  })),
  
  saveWorkflow: async () => {
    const { currentWorkflow, nodes, edges } = get();
    
    if (!currentWorkflow) {
      throw new Error('没有当前工作流');
    }
    
    // 构建工作流定义
    const workflowDef: WorkflowDefinition = {
      ...currentWorkflow,
      nodes: nodes.map((n) => ({
        id: n.id,
        type: n.data.type,
        name: n.data.name,
        position: n.position,
        config: n.data.config || {},
      })),
      edges: edges.map((e) => ({
        source: e.source,
        target: e.target,
      })),
    };
    
    // 调用API保存
    await workflowApi.save(workflowDef);
  },
  
  loadWorkflow: async (id: string) => {
    const workflow = await workflowApi.getById(id);
    
    // 转换为ReactFlow格式
    const nodes: Node[] = workflow.nodes.map((n) => ({
      id: n.id,
      type: 'custom',
      position: n.position,
      data: {
        type: n.type,
        name: n.name,
        config: n.config,
      },
    }));
    
    const edges: Edge[] = workflow.edges.map((e, idx) => ({
      id: `edge-${idx}`,
      source: e.source,
      target: e.target,
      type: 'smoothstep',
    }));
    
    set({
      currentWorkflow: workflow,
      nodes,
      edges,
    });
  },
}));

三、API 调用

api/workflow.ts

import axios from 'axios';
import { WorkflowDefinition, WorkflowExecutionResult } from '@/types/workflow';

const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080';

const api = axios.create({
  baseURL: BASE_URL,
  timeout: 30000,
  headers: {
    'Content-Type': 'application/json',
  },
});

// 请求拦截器添加认证token等
api.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// 响应拦截器(错误处理)
api.interceptors.response.use(
  (response) => response,
  (error) => {
    if (error.response?.status === 401) {
      // 未授权,跳转登录
      window.location.href = '/login';
    }
    return Promise.reject(error);
  }
);

export const workflowApi = {
  /**
   * 创建工作流
   */
  create: async (workflow: WorkflowDefinition): Promise<WorkflowDefinition> => {
    const res = await api.post('/api/workflows', workflow);
    return res.data;
  },
  
  /**
   * 更新工作流
   */
  update: async (id: string, workflow: WorkflowDefinition): Promise<WorkflowDefinition> => {
    const res = await api.put(`/api/workflows/${id}`, workflow);
    return res.data;
  },
  
  /**
   * 保存工作流(自动判断创建或更新)
   */
  save: async (workflow: WorkflowDefinition): Promise<WorkflowDefinition> => {
    if (workflow.id) {
      return workflowApi.update(workflow.id, workflow);
    } else {
      return workflowApi.create(workflow);
    }
  },
  
  /**
   * 获取工作流详情
   */
  getById: async (id: string): Promise<WorkflowDefinition> => {
    const res = await api.get(`/api/workflows/${id}`);
    return res.data;
  },
  
  /**
   * 获取工作流列表
   */
  list: async (status?: string): Promise<WorkflowDefinition[]> => {
    const res = await api.get('/api/workflows', {
      params: { status },
    });
    return res.data;
  },
  
  /**
   * 删除工作流
   */
  delete: async (id: string): Promise<void> => {
    await api.delete(`/api/workflows/${id}`);
  },
  
  /**
   * 执行工作流
   */
  execute: async (
    id: string,
    input: Record<string, any>
  ): Promise<WorkflowExecutionResult> => {
    const res = await api.post(`/api/workflows/${id}/execute`, input);
    return res.data;
  },
  
  /**
   * 获取执行历史
   */
  getExecutions: async (id: string): Promise<any[]> => {
    const res = await api.get(`/api/workflows/${id}/executions`);
    return res.data;
  },
  
  /**
   * 获取执行详情
   */
  getExecutionDetail: async (executionId: string): Promise<any> => {
    const res = await api.get(`/api/workflows/executions/${executionId}`);
    return res.data;
  },
};

四、关键实现细节

4.1 如何确保字段选择器显示正确的字段?

问题:上游节点可能还没执行,怎么知道它会输出什么?

解决方案:使用静态的 outputSchema

// 每个节点类型在注册时定义 outputSchema
const httpRequestMetadata = {
  id: 'http_request',
  outputSchema: {
    type: 'object',
    properties: {
      statusCode: { type: 'number' },
      body: { type: 'object' },  // 实际结构不确定,但至少知道是对象
      headers: { type: 'object' },
    },
  },
};

// 前端根据 schema 构建字段树
// 用户选择: nodes.httpRequest.output.body.email
// 虽然不知道 body 里具体有什么,但可以手动输入 .email

优化方案(第二期):

// 执行一次后,记录真实输出结构
const realOutput = {
  statusCode: 200,
  body: {
    id: 123,
    name: "John",
    email: "john@example.com"
  }
};

// 下次配置时,显示真实字段

4.2 如何实时预览表达式结果?

function ExpressionPreview({ expression, context }: Props) {
  const [result, setResult] = useState<any>(null);
  
  useEffect(() => {
    // 模拟解析(仅用于预览)
    try {
      const resolved = evaluateExpression(expression, context);
      setResult(resolved);
    } catch (e) {
      setResult('错误: ' + e.message);
    }
  }, [expression, context]);
  
  return (
    <div style={{ marginTop: 8, padding: 8, background: '#f5f5f5' }}>
      <Text type="secondary" style={{ fontSize: 11 }}>
        预览结果:
      </Text>
      <pre>{JSON.stringify(result, null, 2)}</pre>
    </div>
  );
}

4.3 如何优化大型工作流的渲染性能?

问题100+个节点时ReactFlow 可能卡顿

解决方案

// 1. 使用 memo 优化节点渲染
const CustomNode = memo(({ data }) => {
  // ...
}, (prev, next) => {
  // 只有数据变化时才重新渲染
  return prev.data === next.data && prev.selected === next.selected;
});

// 2. 虚拟化渲染(仅渲染可见节点)
<ReactFlow
  nodes={nodes}
  edges={edges}
  onlyRenderVisibleElements={true}  // 关键配置
/>

// 3. 节流更新
const throttledUpdate = throttle((nodes) => {
  setNodes(nodes);
}, 100);

五、部署配置

5.1 环境变量

# .env.development
VITE_API_BASE_URL=http://localhost:8080

# .env.production
VITE_API_BASE_URL=https://api.workflow.example.com

5.2 构建配置

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [react()],
  
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
  
  server: {
    port: 3000,
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  
  build: {
    outDir: 'dist',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: {
          'react-vendor': ['react', 'react-dom'],
          'reactflow': ['reactflow'],
          'antd': ['antd', '@ant-design/icons'],
        },
      },
    },
  },
});

5.3 Nginx 配置

server {
    listen 80;
    server_name workflow.example.com;
    
    root /var/www/workflow-frontend/dist;
    index index.html;
    
    # SPA 路由
    location / {
        try_files $uri $uri/ /index.html;
    }
    
    # API 代理
    location /api {
        proxy_pass http://localhost:8080;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
    
    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

六、测试策略

6.1 组件测试

import { render, screen, fireEvent } from '@testing-library/react';
import FieldMappingSelector from './FieldMappingSelector';

describe('FieldMappingSelector', () => {
  it('应该显示上游节点的字段', () => {
    const upstreamNodes = [
      {
        id: 'node1',
        data: { type: 'http_request', name: 'HTTP' },
      },
    ];
    
    const nodeTypes = {
      http_request: {
        outputSchema: {
          properties: {
            statusCode: { type: 'number' },
          },
        },
      },
    };
    
    render(
      <FieldMappingSelector
        upstreamNodes={upstreamNodes}
        nodeTypes={nodeTypes}
      />
    );
    
    // 验证显示了上游节点
    expect(screen.getByText('HTTP')).toBeInTheDocument();
  });
});

下一步:查看 04-数据模型设计.md完整数据库表结构