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

1405 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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