1405 lines
34 KiB
Markdown
1405 lines
34 KiB
Markdown
# 前端技术设计文档
|
||
|
||
**版本**: v1.0
|
||
**关联**: 01-架构总览.md
|
||
|
||
---
|
||
|
||
## 一、技术栈详细说明
|
||
|
||
### 1.1 核心依赖
|
||
|
||
```json
|
||
{
|
||
"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 画布
|
||
|
||
```tsx
|
||
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 - 自定义节点样式
|
||
|
||
```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 - 节点面板
|
||
|
||
```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 - 主组件
|
||
|
||
```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 - 字段映射选择器(核心)⭐⭐⭐
|
||
|
||
```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
|
||
|
||
```typescript
|
||
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
|
||
|
||
```typescript
|
||
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`
|
||
|
||
```typescript
|
||
// 每个节点类型在注册时定义 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
|
||
```
|
||
|
||
**优化方案**(第二期):
|
||
```typescript
|
||
// 执行一次后,记录真实输出结构
|
||
const realOutput = {
|
||
statusCode: 200,
|
||
body: {
|
||
id: 123,
|
||
name: "John",
|
||
email: "john@example.com"
|
||
}
|
||
};
|
||
|
||
// 下次配置时,显示真实字段
|
||
```
|
||
|
||
### 4.2 如何实时预览表达式结果?
|
||
|
||
```tsx
|
||
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 可能卡顿
|
||
|
||
**解决方案**:
|
||
```tsx
|
||
// 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 环境变量
|
||
|
||
```bash
|
||
# .env.development
|
||
VITE_API_BASE_URL=http://localhost:8080
|
||
|
||
# .env.production
|
||
VITE_API_BASE_URL=https://api.workflow.example.com
|
||
```
|
||
|
||
### 5.2 构建配置
|
||
|
||
```typescript
|
||
// 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 配置
|
||
|
||
```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 组件测试
|
||
|
||
```typescript
|
||
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(完整数据库表结构)
|
||
|