34 KiB
34 KiB
前端技术设计文档
版本: 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(完整数据库表结构)