1
This commit is contained in:
parent
33c70b7894
commit
e18874688e
@ -1,94 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Modal, Form, Input, InputNumber, Radio } from 'antd';
|
||||
import { Edge } from '@antv/x6';
|
||||
import { EdgeCondition } from '../nodes/types';
|
||||
|
||||
interface ExpressionModalProps {
|
||||
visible: boolean;
|
||||
edge: Edge;
|
||||
onOk: (condition: EdgeCondition) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ExpressionModal: React.FC<ExpressionModalProps> = ({
|
||||
visible,
|
||||
edge,
|
||||
onOk,
|
||||
onCancel
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const currentCondition = edge.getProp('condition') as EdgeCondition;
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
onOk(values);
|
||||
} catch (error) {
|
||||
console.error('表单验证失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="编辑条件"
|
||||
open={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
type: currentCondition?.type || 'EXPRESSION',
|
||||
expression: currentCondition?.expression || '',
|
||||
priority: currentCondition?.priority || 10
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="type"
|
||||
label="条件类型"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Radio.Group>
|
||||
<Radio value="EXPRESSION">表达式</Radio>
|
||||
<Radio value="DEFAULT">默认路径</Radio>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}
|
||||
>
|
||||
{({ getFieldValue }) => {
|
||||
const type = getFieldValue('type');
|
||||
return type === 'EXPRESSION' ? (
|
||||
<Form.Item
|
||||
name="expression"
|
||||
label="条件表达式"
|
||||
rules={[{ required: true, message: '请输入条件表达式' }]}
|
||||
extra="支持使用 ${变量名} 引用流程变量,例如:${amount} > 1000"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入条件表达式"
|
||||
rows={4}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null;
|
||||
}}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="priority"
|
||||
label="优先级"
|
||||
rules={[{ required: true }]}
|
||||
extra="数字越小优先级越高"
|
||||
>
|
||||
<InputNumber min={1} max={999} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExpressionModal;
|
||||
@ -1,92 +1,247 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { Cell } from '@antv/x6';
|
||||
import type { WorkflowNodeDefinition, ConfigurableNodeDefinition } from "../nodes/types";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { convertJsonSchemaToColumns } from '@/utils/jsonSchemaUtils';
|
||||
import { BetaSchemaForm } from '@ant-design/pro-components';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Drawer, Tabs, Button, Space, message } from 'antd';
|
||||
import { SaveOutlined, ReloadOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { FormItem, Input, NumberPicker, Select, FormLayout, Switch } from '@formily/antd-v5';
|
||||
import { createForm } from '@formily/core';
|
||||
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
|
||||
import type { FlowNode, FlowNodeData } from '../types';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
import { isConfigurableNode } from '../nodes/types';
|
||||
|
||||
// 创建Schema组件
|
||||
const SchemaField = createSchemaField({
|
||||
components: {
|
||||
FormItem,
|
||||
Input,
|
||||
NumberPicker,
|
||||
Select,
|
||||
FormLayout,
|
||||
Switch,
|
||||
'Input.TextArea': Input.TextArea,
|
||||
},
|
||||
});
|
||||
|
||||
interface NodeConfigModalProps {
|
||||
visible: boolean;
|
||||
node: Cell | null;
|
||||
nodeDefinition: WorkflowNodeDefinition | null;
|
||||
onOk: (values: any) => void;
|
||||
node: FlowNode | null;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
configs?: Record<string, any>;
|
||||
inputMapping?: Record<string, any>;
|
||||
outputMapping?: Record<string, any>;
|
||||
onOk: (nodeId: string, updatedData: Partial<FlowNodeData>) => void;
|
||||
}
|
||||
|
||||
const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
visible,
|
||||
node,
|
||||
nodeDefinition,
|
||||
onOk,
|
||||
onCancel,
|
||||
onOk
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<FormData>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
|
||||
// 判断是否为可配置节点
|
||||
const isConfigurableNode = (def: WorkflowNodeDefinition): def is ConfigurableNodeDefinition => {
|
||||
return 'inputMappingSchema' in def || 'outputMappingSchema' in def;
|
||||
};
|
||||
// 获取节点定义
|
||||
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
|
||||
|
||||
// 创建Formily表单实例
|
||||
const configForm = useMemo(() => createForm(), []);
|
||||
const inputForm = useMemo(() => createForm(), []);
|
||||
const outputForm = useMemo(() => createForm(), []);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (nodeDefinition && node) {
|
||||
// 从节点数据中获取现有配置
|
||||
const nodeData = node.getData() || {};
|
||||
if (visible && node && nodeDefinition) {
|
||||
const nodeData = node.data || {};
|
||||
|
||||
// 准备默认的基本信息配置
|
||||
// 准备默认配置
|
||||
const defaultConfig = {
|
||||
nodeName: nodeDefinition.nodeName, // 默认节点名称
|
||||
nodeCode: nodeDefinition.nodeCode, // 默认节点编码
|
||||
description: nodeDefinition.description // 默认节点描述
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description
|
||||
};
|
||||
|
||||
// 合并默认值和已保存的配置
|
||||
setFormData({
|
||||
configs: { ...defaultConfig, ...(nodeData.configs || {}) },
|
||||
inputMapping: nodeData.inputMapping || {},
|
||||
outputMapping: nodeData.outputMapping || {},
|
||||
});
|
||||
} else {
|
||||
setFormData({});
|
||||
// 设置表单初始值
|
||||
configForm.setInitialValues({ ...defaultConfig, ...(nodeData.configs || {}) });
|
||||
configForm.reset();
|
||||
|
||||
if (isConfigurableNode(nodeDefinition)) {
|
||||
inputForm.setInitialValues(nodeData.inputMapping || {});
|
||||
inputForm.reset();
|
||||
|
||||
outputForm.setInitialValues(nodeData.outputMapping || {});
|
||||
outputForm.reset();
|
||||
}
|
||||
}, [nodeDefinition, node]);
|
||||
}
|
||||
}, [visible, node, nodeDefinition, configForm, inputForm, outputForm]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onOk(formData);
|
||||
// 递归处理表单值,将JSON字符串转换为对象
|
||||
const processFormValues = (values: Record<string, any>, schema: ISchema | undefined): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
if (!schema?.properties || typeof schema.properties !== 'object') return values;
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const propSchema = (schema.properties as Record<string, any>)?.[key];
|
||||
|
||||
// 如果是object类型且值是字符串,尝试解析
|
||||
if (propSchema?.type === 'object' && typeof value === 'string') {
|
||||
try {
|
||||
result[key] = JSON.parse(value);
|
||||
} catch {
|
||||
result[key] = value; // 解析失败保持原值
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleConfigChange = (values: Record<string, any>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
configs: values
|
||||
}));
|
||||
const handleSubmit = async () => {
|
||||
if (!node || !nodeDefinition) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 获取表单值并转换
|
||||
const configs = processFormValues(configForm.values, nodeDefinition.configSchema);
|
||||
const inputMapping = isConfigurableNode(nodeDefinition)
|
||||
? processFormValues(inputForm.values, nodeDefinition.inputMappingSchema)
|
||||
: {};
|
||||
const outputMapping = isConfigurableNode(nodeDefinition)
|
||||
? processFormValues(outputForm.values, nodeDefinition.outputMappingSchema)
|
||||
: {};
|
||||
|
||||
const updatedData: Partial<FlowNodeData> = {
|
||||
label: configs.nodeName || node.data.label,
|
||||
configs,
|
||||
inputMapping,
|
||||
outputMapping,
|
||||
};
|
||||
|
||||
const handleInputMappingChange = (values: Record<string, any>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
inputMapping: values
|
||||
}));
|
||||
onOk(node.id, updatedData);
|
||||
message.success('节点配置保存成功');
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOutputMappingChange = (values: Record<string, any>) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
outputMapping: values
|
||||
}));
|
||||
const handleReset = () => {
|
||||
configForm.reset();
|
||||
inputForm.reset();
|
||||
outputForm.reset();
|
||||
message.info('已重置为初始值');
|
||||
};
|
||||
|
||||
// 将JSON Schema转换为Formily Schema(扩展配置)
|
||||
const convertToFormilySchema = (jsonSchema: ISchema): ISchema => {
|
||||
const schema: ISchema = {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
};
|
||||
|
||||
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') return schema;
|
||||
|
||||
Object.entries(jsonSchema.properties as Record<string, any>).forEach(([key, prop]: [string, any]) => {
|
||||
const field: any = {
|
||||
title: prop.title || key,
|
||||
description: prop.description,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {
|
||||
tooltip: prop.description,
|
||||
labelCol: 6, // 标签占6列
|
||||
wrapperCol: 18, // 内容占18列(剩余空间)
|
||||
},
|
||||
};
|
||||
|
||||
// 根据类型设置组件
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
if (prop.enum) {
|
||||
field['x-component'] = 'Select';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' }, // 统一宽度
|
||||
options: prop.enum.map((v: any, i: number) => ({
|
||||
label: prop.enumNames?.[i] || v,
|
||||
value: v
|
||||
}))
|
||||
};
|
||||
} else if (prop.format === 'password') {
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
type: 'password',
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
} else {
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
field['x-component'] = 'NumberPicker';
|
||||
field['x-component-props'] = {
|
||||
min: prop.minimum,
|
||||
max: prop.maximum,
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
break;
|
||||
case 'boolean':
|
||||
field['x-component'] = 'Switch';
|
||||
break;
|
||||
case 'object':
|
||||
field['x-component'] = 'Input.TextArea';
|
||||
field['x-component-props'] = {
|
||||
rows: 4,
|
||||
style: { width: '100%' }, // 统一宽度
|
||||
placeholder: '请输入JSON格式,例如:{"key": "value"}'
|
||||
};
|
||||
// ✅ 关键修复:将object类型的default值转换为JSON字符串
|
||||
if (prop.default !== undefined && typeof prop.default === 'object') {
|
||||
field.default = JSON.stringify(prop.default, null, 2);
|
||||
} else {
|
||||
field.default = prop.default;
|
||||
}
|
||||
// Formily会自动处理object的序列化
|
||||
field['x-validator'] = (value: any) => {
|
||||
if (!value) return true;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return '请输入有效的JSON格式';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
break;
|
||||
default:
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
}
|
||||
|
||||
// 设置默认值(非object类型)
|
||||
if (prop.type !== 'object' && prop.default !== undefined) {
|
||||
field.default = prop.default;
|
||||
}
|
||||
|
||||
// 设置必填
|
||||
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) {
|
||||
field.required = true;
|
||||
}
|
||||
|
||||
(schema.properties as Record<string, any>)[key] = field;
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
if (!nodeDefinition) {
|
||||
@ -98,18 +253,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
{
|
||||
key: 'config',
|
||||
label: '基本配置',
|
||||
children: (
|
||||
children: activeTab === 'config' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<BetaSchemaForm
|
||||
key={`configs-${node?.id}-${JSON.stringify(formData.configs)}`}
|
||||
layoutType="Form"
|
||||
columns={convertJsonSchemaToColumns(nodeDefinition.configSchema as any)}
|
||||
initialValues={formData.configs}
|
||||
onValuesChange={(_, allValues) => handleConfigChange(allValues)}
|
||||
submitter={false}
|
||||
/>
|
||||
<FormProvider form={configForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.configSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
),
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
@ -119,18 +271,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
tabItems.push({
|
||||
key: 'input',
|
||||
label: '输入映射',
|
||||
children: (
|
||||
children: activeTab === 'input' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<BetaSchemaForm
|
||||
key={`input-${node?.id}-${JSON.stringify(formData.inputMapping)}`}
|
||||
layoutType="Form"
|
||||
columns={convertJsonSchemaToColumns(nodeDefinition.inputMappingSchema as any)}
|
||||
initialValues={formData.inputMapping}
|
||||
onValuesChange={(_, allValues) => handleInputMappingChange(allValues)}
|
||||
submitter={false}
|
||||
/>
|
||||
<FormProvider form={inputForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.inputMappingSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
),
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
|
||||
@ -138,66 +287,81 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
tabItems.push({
|
||||
key: 'output',
|
||||
label: '输出映射',
|
||||
children: (
|
||||
children: activeTab === 'output' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<BetaSchemaForm
|
||||
key={`output-${node?.id}-${JSON.stringify(formData.outputMapping)}`}
|
||||
layoutType="Form"
|
||||
columns={convertJsonSchemaToColumns(nodeDefinition.outputMappingSchema as any)}
|
||||
initialValues={formData.outputMapping}
|
||||
onValuesChange={(_, allValues) => handleOutputMappingChange(allValues)}
|
||||
submitter={false}
|
||||
/>
|
||||
<FormProvider form={outputForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.outputMappingSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
),
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={visible} onOpenChange={(open) => !open && onCancel()}>
|
||||
<SheetContent
|
||||
style={{
|
||||
width: '600px',
|
||||
maxWidth: '90vw',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<Drawer
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: '24px' }}>
|
||||
<span style={{ fontSize: '16px', fontWeight: '600' }}>
|
||||
编辑节点 - {nodeDefinition.nodeName}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onCancel}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={720}
|
||||
open={visible}
|
||||
onClose={onCancel}
|
||||
closeIcon={null}
|
||||
styles={{
|
||||
body: { padding: '0 24px 24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
}}
|
||||
footer={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ paddingTop: '16px' }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
className="flex-tabs"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
borderTop: '1px solid #f0f0f0',
|
||||
padding: '16px 0 0 0',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '8px'
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit}>
|
||||
确定
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,91 +1,30 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {Card, Collapse, Tooltip, message} from 'antd';
|
||||
import type {NodeCategory} from '../nodes/types';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
StopOutlined,
|
||||
UserOutlined,
|
||||
ApiOutlined,
|
||||
CodeOutlined,
|
||||
NodeIndexOutlined,
|
||||
SplitCellsOutlined,
|
||||
AppstoreOutlined,
|
||||
BranchesOutlined
|
||||
} from '@ant-design/icons';
|
||||
import {getNodeDefinitionList} from "../nodes/nodeService";
|
||||
import type {WorkflowNodeDefinition} from "../nodes/types";
|
||||
import React from 'react';
|
||||
import { NodeCategory } from '../types';
|
||||
import { NODE_DEFINITIONS } from '../nodes';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
|
||||
// 使用 Collapse 组件,不需要解构 Panel
|
||||
|
||||
// 图标映射配置
|
||||
const iconMap: Record<string, any> = {
|
||||
'play-circle': PlayCircleOutlined,
|
||||
'stop': StopOutlined,
|
||||
'user': UserOutlined,
|
||||
'api': ApiOutlined,
|
||||
'code': CodeOutlined,
|
||||
'build': CodeOutlined, // 构建任务图标
|
||||
'fork': NodeIndexOutlined,
|
||||
'branches': SplitCellsOutlined,
|
||||
'apartment': AppstoreOutlined
|
||||
};
|
||||
|
||||
// 节点类型到图标的映射
|
||||
const typeIconMap: Record<string, any> = {
|
||||
'START_EVENT': PlayCircleOutlined,
|
||||
'END_EVENT': StopOutlined,
|
||||
'USER_TASK': UserOutlined,
|
||||
'SERVICE_TASK': ApiOutlined,
|
||||
'SCRIPT_TASK': CodeOutlined,
|
||||
'DEPLOY_NODE': CodeOutlined, // 构建任务节点
|
||||
'EXCLUSIVE_GATEWAY': NodeIndexOutlined,
|
||||
'PARALLEL_GATEWAY': SplitCellsOutlined,
|
||||
'SUB_PROCESS': AppstoreOutlined,
|
||||
'CALL_ACTIVITY': BranchesOutlined
|
||||
};
|
||||
|
||||
// 节点分类配置
|
||||
const categoryConfig: Record<NodeCategory, {
|
||||
label: string;
|
||||
key: string;
|
||||
}> = {
|
||||
EVENT: {label: '事件节点', key: '1'},
|
||||
TASK: {label: '任务节点', key: '2'},
|
||||
GATEWAY: {label: '网关节点', key: '3'},
|
||||
CONTAINER: {label: '容器节点', key: '4'},
|
||||
// 图标映射函数
|
||||
const getNodeIcon = (iconName: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'play-circle': '▶️',
|
||||
'stop-circle': '⏹️',
|
||||
'user': '👤',
|
||||
'api': '⚙️',
|
||||
'code': '📜',
|
||||
'build': '🚀',
|
||||
'jenkins': '🔨',
|
||||
'gateway': '💎'
|
||||
};
|
||||
return iconMap[iconName] || '📋';
|
||||
};
|
||||
|
||||
interface NodePanelProps {
|
||||
onNodeDragStart?: (node: WorkflowNodeDefinition, e: React.DragEvent) => void,
|
||||
nodeDefinitions?: WorkflowNodeDefinition[]
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nodeDefinitions, setNodeDefinitions] = useState<WorkflowNodeDefinition[]>([]);
|
||||
|
||||
// 加载节点定义列表
|
||||
const loadNodeDefinitions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getNodeDefinitionList();
|
||||
console.log(data)
|
||||
setNodeDefinitions(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadNodeDefinitions();
|
||||
}, []);
|
||||
|
||||
// 按分类对节点进行分组
|
||||
const groupedNodes = nodeDefinitions.reduce((acc, node) => {
|
||||
const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
|
||||
// 按分类分组节点
|
||||
const nodesByCategory = NODE_DEFINITIONS.reduce((acc, node) => {
|
||||
if (!acc[node.category]) {
|
||||
acc[node.category] = [];
|
||||
}
|
||||
@ -93,145 +32,156 @@ const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => {
|
||||
return acc;
|
||||
}, {} as Record<NodeCategory, WorkflowNodeDefinition[]>);
|
||||
|
||||
// 处理节点拖拽开始事件
|
||||
const handleDragStart = (node: WorkflowNodeDefinition, e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('node', JSON.stringify(node));
|
||||
onNodeDragStart?.(node, e);
|
||||
// 拖拽开始处理
|
||||
const handleDragStart = (event: React.DragEvent, nodeDefinition: WorkflowNodeDefinition) => {
|
||||
event.dataTransfer.setData('application/reactflow', JSON.stringify({
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
nodeDefinition
|
||||
}));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 渲染节点图标
|
||||
const renderNodeIcon = (node: WorkflowNodeDefinition) => {
|
||||
const iconName = node.uiConfig?.style.icon;
|
||||
// 首先尝试使用配置的图标
|
||||
let IconComponent = iconMap[iconName];
|
||||
// 渲染节点项
|
||||
const renderNodeItem = (nodeDefinition: WorkflowNodeDefinition) => (
|
||||
<div
|
||||
key={nodeDefinition.nodeCode}
|
||||
draggable
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
marginBottom: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = nodeDefinition.uiConfig.style.fill;
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.currentTarget.style.cursor = 'grabbing';
|
||||
handleDragStart(e, nodeDefinition);
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
e.currentTarget.style.cursor = 'grab';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
width: '20px',
|
||||
textAlign: 'center',
|
||||
color: nodeDefinition.uiConfig.style.fill
|
||||
}}>
|
||||
{getNodeIcon(nodeDefinition.uiConfig.style.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
{nodeDefinition.nodeName}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#6b7280',
|
||||
lineHeight: '1.2',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{nodeDefinition.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 如果没有找到对应的图标,使用节点类型对应的默认图标
|
||||
if (!IconComponent) {
|
||||
IconComponent = typeIconMap[node.nodeType] || AppstoreOutlined;
|
||||
}
|
||||
// 分类标题映射
|
||||
const categoryTitles = {
|
||||
[NodeCategory.EVENT]: '🎯 事件节点',
|
||||
[NodeCategory.TASK]: '📋 任务节点',
|
||||
[NodeCategory.GATEWAY]: '🔀 网关节点',
|
||||
[NodeCategory.CONTAINER]: '📦 容器节点'
|
||||
};
|
||||
|
||||
return (
|
||||
<IconComponent
|
||||
style={{
|
||||
color: node.uiConfig?.style.iconColor || '#1890ff',
|
||||
fontSize: '16px',
|
||||
marginRight: '6px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getNodeItemStyle = (node: WorkflowNodeDefinition) => ({
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
border: `1px solid ${node.uiConfig?.style.stroke}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'move',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '10px',
|
||||
background: node.uiConfig?.style.fill,
|
||||
transition: 'all 0.3s',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.05)',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-1px)',
|
||||
boxShadow: '0 3px 6px rgba(0,0,0,0.1)',
|
||||
}
|
||||
});
|
||||
|
||||
const tooltipStyle = {
|
||||
maxWidth: '300px'
|
||||
};
|
||||
|
||||
const tooltipOverlayInnerStyle = {
|
||||
padding: '12px 16px'
|
||||
};
|
||||
|
||||
// 构建折叠面板的 items,只包含有节点的分类
|
||||
const collapseItems = Object.entries(categoryConfig)
|
||||
.filter(([category]) => groupedNodes[category as NodeCategory]?.length > 0) // 过滤掉没有节点的分类
|
||||
.map(([category, {label, key}]) => ({
|
||||
key,
|
||||
label: <span style={{fontSize: '14px', fontWeight: 500}}>{label}</span>,
|
||||
children: (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
padding: '8px 4px'
|
||||
}}>
|
||||
{groupedNodes[category as NodeCategory]?.map(node => (
|
||||
<Tooltip
|
||||
key={node.nodeCode}
|
||||
title={
|
||||
<div>
|
||||
<div style={{fontSize: '14px', fontWeight: 500}}>{node.description}</div>
|
||||
</div>
|
||||
}
|
||||
styles={{
|
||||
root: tooltipStyle,
|
||||
body: tooltipOverlayInnerStyle
|
||||
}}
|
||||
placement="right"
|
||||
arrow={false}
|
||||
>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(node, e)}
|
||||
style={getNodeItemStyle(node)}
|
||||
>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '6px'
|
||||
}}>
|
||||
{renderNodeIcon(node)}
|
||||
<span style={{
|
||||
fontSize: '13px',
|
||||
color: '#262626',
|
||||
<div className={`node-panel ${className}`} style={{
|
||||
width: '260px',
|
||||
height: '100%',
|
||||
background: '#f8fafc',
|
||||
borderRight: '1px solid #e5e7eb',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{node.nodeName}({node.nodeType})</span>
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
节点面板
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
拖拽节点到画布创建工作流
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: '1',
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{Object.entries(nodesByCategory).map(([category, nodes]) => (
|
||||
<div key={category} style={{ marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
padding: '4px 0',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{categoryTitles[category as NodeCategory]}
|
||||
</div>
|
||||
{nodes.map(renderNodeItem)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="流程节点"
|
||||
size="small"
|
||||
loading={loading}
|
||||
styles={{
|
||||
header: {
|
||||
padding: '12px 16px',
|
||||
fontSize: '15px',
|
||||
fontWeight: 500,
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
},
|
||||
body: {
|
||||
{/* 使用提示 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
height: 'calc(100vh - 250px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Collapse
|
||||
defaultActiveKey={['1', '2', '3', '4']}
|
||||
ghost
|
||||
items={collapseItems}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
background: '#f1f5f9',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
💡 提示:
|
||||
<br />• 拖拽节点到画布创建
|
||||
<br />• 双击节点进行配置
|
||||
<br />• 连接节点创建流程
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import NodePanel from './NodePanel';
|
||||
import type { WorkflowNodeDefinition } from "../nodes/nodeService";
|
||||
|
||||
interface WorkflowCanvasProps {
|
||||
graphContainerRef: React.RefObject<HTMLDivElement>;
|
||||
minimapContainerRef: React.RefObject<HTMLDivElement>;
|
||||
nodeDefinitions: NodeDefinitionResponse[];
|
||||
onNodeDragStart: (node: NodeDefinitionResponse, e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流画布组件
|
||||
*/
|
||||
const WorkflowCanvas: React.FC<WorkflowCanvasProps> = ({
|
||||
graphContainerRef,
|
||||
minimapContainerRef,
|
||||
nodeDefinitions,
|
||||
onNodeDragStart,
|
||||
onDrop,
|
||||
onDragOver
|
||||
}) => {
|
||||
return (
|
||||
<div className="content">
|
||||
<div className="sidebar">
|
||||
<NodePanel
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
/>
|
||||
</div>
|
||||
<div className="main-area">
|
||||
<div className="workflow-container">
|
||||
<div
|
||||
ref={graphContainerRef}
|
||||
className="workflow-canvas"
|
||||
onDrop={onDrop}
|
||||
onDragOver={onDragOver}
|
||||
/>
|
||||
<div ref={minimapContainerRef} className="minimap-container"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowCanvas;
|
||||
@ -1,163 +1,168 @@
|
||||
import React from 'react';
|
||||
import { Button, Space, Tooltip, Modal } from 'antd';
|
||||
import { Button, Divider, Tooltip } from 'antd';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
SaveOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
UndoOutlined,
|
||||
RedoOutlined,
|
||||
ScissorOutlined,
|
||||
SnippetsOutlined,
|
||||
SelectOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
ExpandOutlined,
|
||||
ArrowLeftOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface WorkflowToolbarProps {
|
||||
title: string;
|
||||
scale: number;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onCopy: () => void;
|
||||
onCut: () => void;
|
||||
onPaste: () => void;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
onSelectAll: () => void;
|
||||
onDelete: () => void;
|
||||
title?: string;
|
||||
onSave?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onFitView?: () => void;
|
||||
onBack?: () => void;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
zoom?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流设计器工具栏组件
|
||||
*/
|
||||
const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
|
||||
title,
|
||||
scale,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onBack,
|
||||
title = '工作流设计器',
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onCopy,
|
||||
onCut,
|
||||
onPaste,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onSelectAll,
|
||||
onDelete
|
||||
onFitView,
|
||||
onBack,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
zoom = 1,
|
||||
className = ''
|
||||
}) => {
|
||||
const handleDelete = () => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除选中的元素吗?',
|
||||
onOk: onDelete
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<Space>
|
||||
<Tooltip title="返回">
|
||||
<div
|
||||
className={`workflow-toolbar ${className}`}
|
||||
style={{
|
||||
height: '56px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
{/* 左侧:返回按钮和标题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Tooltip title="返回列表">
|
||||
<Button
|
||||
className="back-button"
|
||||
icon={<ArrowLeftOutlined/>}
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
style={{ color: '#6b7280' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span>{title}</span>
|
||||
</Space>
|
||||
<div className="actions">
|
||||
<Space>
|
||||
<Space.Compact>
|
||||
<Tooltip title="撤销">
|
||||
<Divider type="vertical" />
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮区域 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{/* 撤销/重做 */}
|
||||
<Tooltip title="撤销 (Ctrl+Z)">
|
||||
<Button
|
||||
icon={<UndoOutlined/>}
|
||||
type="text"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
>
|
||||
撤销
|
||||
</Button>
|
||||
size="middle"
|
||||
style={{
|
||||
color: canUndo ? '#374151' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做">
|
||||
<Tooltip title="重做 (Ctrl+Shift+Z)">
|
||||
<Button
|
||||
icon={<RedoOutlined/>}
|
||||
type="text"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
>
|
||||
重做
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
<Space.Compact>
|
||||
<Tooltip title="剪切">
|
||||
<Button
|
||||
icon={<ScissorOutlined/>}
|
||||
onClick={onCut}
|
||||
size="middle"
|
||||
style={{
|
||||
color: canRedo ? '#374151' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制">
|
||||
|
||||
<Divider type="vertical" style={{ margin: '0 4px' }} />
|
||||
|
||||
{/* 视图操作 */}
|
||||
<Tooltip title="放大 (+)">
|
||||
<Button
|
||||
icon={<CopyOutlined/>}
|
||||
onClick={onCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="粘贴">
|
||||
<Button
|
||||
icon={<SnippetsOutlined/>}
|
||||
onClick={onPaste}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
<Space.Compact>
|
||||
<Tooltip title="放大">
|
||||
<Button
|
||||
icon={<ZoomInOutlined/>}
|
||||
type="text"
|
||||
icon={<ZoomInOutlined />}
|
||||
onClick={onZoomIn}
|
||||
disabled={scale >= 2}
|
||||
>
|
||||
放大
|
||||
</Button>
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小">
|
||||
<Tooltip title="缩小 (-)">
|
||||
<Button
|
||||
icon={<ZoomOutOutlined/>}
|
||||
type="text"
|
||||
icon={<ZoomOutOutlined />}
|
||||
onClick={onZoomOut}
|
||||
disabled={scale <= 0.2}
|
||||
>
|
||||
缩小
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
<Space.Compact>
|
||||
<Tooltip title="全选">
|
||||
<Button
|
||||
icon={<SelectOutlined/>}
|
||||
onClick={onSelectAll}
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Tooltip title="适应视图">
|
||||
<Button
|
||||
icon={<DeleteOutlined/>}
|
||||
onClick={handleDelete}
|
||||
type="text"
|
||||
icon={<ExpandOutlined />}
|
||||
onClick={onFitView}
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space.Compact>
|
||||
<Tooltip title="保存">
|
||||
|
||||
{/* 缩放比例显示 */}
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#475569',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
minWidth: '60px',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
|
||||
<Divider type="vertical" style={{ margin: '0 8px' }} />
|
||||
|
||||
{/* 保存按钮(最右侧) */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined/>}
|
||||
icon={<SaveOutlined />}
|
||||
onClick={onSave}
|
||||
size="middle"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.2)',
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { message } from 'antd';
|
||||
import { Graph } from '@antv/x6';
|
||||
import { getDefinitionDetail, saveDefinition } from '../../Definition/service';
|
||||
import { getNodeDefinitionList } from '../nodes/nodeService';
|
||||
import { validateWorkflow } from '../utils/validator';
|
||||
import { restoreNodeFromData } from '../utils/nodeUtils';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 工作流数据管理 Hook
|
||||
*/
|
||||
export const useWorkflowData = () => {
|
||||
const [nodeDefinitions, setNodeDefinitions] = useState<WorkflowNodeDefinition[]>([]);
|
||||
const [isNodeDefinitionsLoaded, setIsNodeDefinitionsLoaded] = useState(false);
|
||||
const [definitionData, setDefinitionData] = useState<any>(null);
|
||||
const [title, setTitle] = useState<string>('工作流设计');
|
||||
|
||||
// 加载节点定义列表
|
||||
useEffect(() => {
|
||||
const loadNodeDefinitions = async () => {
|
||||
try {
|
||||
const data = await getNodeDefinitionList();
|
||||
setNodeDefinitions(data);
|
||||
setIsNodeDefinitionsLoaded(true);
|
||||
} catch (error) {
|
||||
console.error('加载节点定义失败:', error);
|
||||
message.error('加载节点定义失败');
|
||||
}
|
||||
};
|
||||
loadNodeDefinitions();
|
||||
}, []);
|
||||
|
||||
// 加载工作流定义详情
|
||||
const loadDefinitionDetail = useCallback(async (graphInstance: Graph, definitionId: string) => {
|
||||
try {
|
||||
console.log('正在加载工作流定义详情, ID:', definitionId);
|
||||
const response = await getDefinitionDetail(Number(definitionId));
|
||||
console.log('工作流定义详情加载成功:', response);
|
||||
setTitle(`工作流设计 - ${response.name}`);
|
||||
setDefinitionData(response);
|
||||
|
||||
if (!graphInstance) {
|
||||
console.error('Graph instance is not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
// 确保节点定义已加载
|
||||
if (!nodeDefinitions || nodeDefinitions.length === 0) {
|
||||
console.error('节点定义未加载,无法还原工作流');
|
||||
return;
|
||||
}
|
||||
|
||||
// 清空画布
|
||||
graphInstance.clearCells();
|
||||
const nodeMap = new Map();
|
||||
|
||||
// 创建节点
|
||||
response.graph?.nodes?.forEach((existingNode: any) => {
|
||||
console.log('正在还原节点:', existingNode.nodeType, existingNode);
|
||||
|
||||
// 查找节点定义
|
||||
const nodeDefinition = nodeDefinitions.find(def => def.nodeType === existingNode.nodeType);
|
||||
if (!nodeDefinition) {
|
||||
console.error('找不到节点定义:', existingNode.nodeType);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复节点
|
||||
const node = restoreNodeFromData(graphInstance, existingNode, nodeDefinition);
|
||||
if (!node) {
|
||||
console.error('节点创建失败:', existingNode);
|
||||
return;
|
||||
}
|
||||
|
||||
nodeMap.set(existingNode.id, node);
|
||||
console.log('节点创建成功,ID:', node.id, '映射 ID:', existingNode.id);
|
||||
});
|
||||
|
||||
console.log('所有节点创建完成,nodeMap:', nodeMap);
|
||||
console.log('准备创建边:', response.graph?.edges);
|
||||
|
||||
// 创建边
|
||||
response.graph?.edges?.forEach((edge: any) => {
|
||||
const sourceNode = nodeMap.get(edge.from);
|
||||
const targetNode = nodeMap.get(edge.to);
|
||||
if (sourceNode && targetNode) {
|
||||
// 根据节点类型获取正确的端口组
|
||||
const getPortByGroup = (node: any, group: string) => {
|
||||
const ports = node.getPorts();
|
||||
const port = ports.find((p: any) => p.group === group);
|
||||
return port?.id;
|
||||
};
|
||||
|
||||
// 获取源节点的输出端口(一定是out组)
|
||||
const sourcePort = getPortByGroup(sourceNode, 'out');
|
||||
|
||||
// 获取目标节点的输入端口(一定是in组)
|
||||
const targetPort = getPortByGroup(targetNode, 'in');
|
||||
|
||||
if (!sourcePort || !targetPort) {
|
||||
console.error('无法找到正确的端口:', edge);
|
||||
return;
|
||||
}
|
||||
|
||||
const newEdge = graphInstance.addEdge({
|
||||
source: {
|
||||
cell: sourceNode.id,
|
||||
port: sourcePort,
|
||||
},
|
||||
target: {
|
||||
cell: targetNode.id,
|
||||
port: targetPort,
|
||||
},
|
||||
vertices: edge?.vertices || [], // 恢复顶点信息
|
||||
attrs: {
|
||||
line: {
|
||||
stroke: '#5F95FF',
|
||||
strokeWidth: 2,
|
||||
targetMarker: {
|
||||
name: 'classic',
|
||||
size: 7,
|
||||
},
|
||||
},
|
||||
},
|
||||
labels: [{
|
||||
attrs: {
|
||||
label: {
|
||||
text: edge.config?.condition?.expression || edge.name || ''
|
||||
}
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// 设置边的条件属性
|
||||
if (edge.config?.condition) {
|
||||
newEdge.setProp('condition', edge.config.condition);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('加载工作流定义失败:', error);
|
||||
message.error('加载工作流定义失败');
|
||||
}
|
||||
}, [nodeDefinitions]);
|
||||
|
||||
|
||||
// 保存工作流
|
||||
const saveWorkflow = useCallback(async (graph: Graph) => {
|
||||
if (!graph) {
|
||||
console.error('Graph 实例为空');
|
||||
message.error('图形实例不存在,无法保存');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!definitionData) {
|
||||
console.error('definitionData 为空 - 工作流定义数据未加载');
|
||||
message.error('工作流定义数据未加载,请刷新页面重试');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 校验流程图
|
||||
const validationResult = validateWorkflow(graph);
|
||||
if (!validationResult.valid) {
|
||||
message.error(validationResult.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取所有节点和边的数据 - 只保存业务数据,不保存UI配置
|
||||
const nodes = graph.getNodes().map(node => {
|
||||
const nodeData = node.getData();
|
||||
const position = node.getPosition();
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
nodeCode: nodeData.nodeCode,
|
||||
nodeType: nodeData.nodeType,
|
||||
nodeName: nodeData.nodeName,
|
||||
position, // 只保存位置信息
|
||||
configs: nodeData.configs || {}, // 节点配置数据
|
||||
inputMapping: nodeData.inputMapping || {}, // 输入映射数据
|
||||
outputMapping: nodeData.outputMapping || {} // 输出映射数据
|
||||
};
|
||||
});
|
||||
|
||||
const edges = graph.getEdges().map(edge => {
|
||||
const condition = edge.getProp('condition');
|
||||
const vertices = edge.getVertices(); // 获取边的顶点信息
|
||||
|
||||
return {
|
||||
id: edge.id,
|
||||
from: edge.getSourceCellId(),
|
||||
to: edge.getTargetCellId(),
|
||||
name: edge.getLabels()?.[0]?.attrs?.label?.text || '',
|
||||
config: {
|
||||
type: 'sequence',
|
||||
condition: condition || undefined
|
||||
},
|
||||
vertices: vertices // 保存顶点信息
|
||||
};
|
||||
});
|
||||
|
||||
// 构建保存数据 - 只保存图形结构,不保存Schema配置
|
||||
const saveData = {
|
||||
...definitionData,
|
||||
graph: {
|
||||
nodes,
|
||||
edges
|
||||
}
|
||||
};
|
||||
|
||||
// 调用保存接口
|
||||
await saveDefinition(saveData);
|
||||
|
||||
message.success('保存成功');
|
||||
} catch (error) {
|
||||
console.error('保存流程失败:', error);
|
||||
message.error('保存流程失败');
|
||||
}
|
||||
}, [nodeDefinitions, definitionData]);
|
||||
|
||||
return {
|
||||
nodeDefinitions,
|
||||
isNodeDefinitionsLoaded,
|
||||
definitionData,
|
||||
title,
|
||||
loadDefinitionDetail,
|
||||
saveWorkflow
|
||||
};
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import { message } from 'antd';
|
||||
import { createNodeFromDefinition } from '../utils/nodeUtils';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 工作流拖拽功能 Hook
|
||||
*/
|
||||
export const useWorkflowDragDrop = (
|
||||
graph: Graph | null,
|
||||
nodeDefinitions: WorkflowNodeDefinition[]
|
||||
) => {
|
||||
// 处理节点拖拽开始
|
||||
const handleNodeDragStart = (node: WorkflowNodeDefinition, e: React.DragEvent) => {
|
||||
e.dataTransfer.setData('node', JSON.stringify(node));
|
||||
};
|
||||
|
||||
// 处理节点拖拽结束
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!graph) return;
|
||||
|
||||
try {
|
||||
const nodeData = e.dataTransfer.getData('node');
|
||||
if (!nodeData) return;
|
||||
|
||||
const nodeDefinition = JSON.parse(nodeData);
|
||||
const {clientX, clientY} = e;
|
||||
const point = graph.clientToLocal({x: clientX, y: clientY});
|
||||
createNodeFromDefinition(graph, nodeDefinition, point);
|
||||
} catch (error) {
|
||||
console.error('创建节点失败:', error);
|
||||
message.error('创建节点失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 处理拖拽过程中
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
handleDrop,
|
||||
handleDragOver
|
||||
};
|
||||
};
|
||||
@ -1,187 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Graph, Edge } from '@antv/x6';
|
||||
import { message } from 'antd';
|
||||
import { GraphInitializer } from '../utils/graph/graphInitializer';
|
||||
import { EventRegistrar } from '../utils/graph/eventRegistrar';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 工作流图形管理 Hook
|
||||
*/
|
||||
export const useWorkflowGraph = (
|
||||
nodeDefinitions: WorkflowNodeDefinition[],
|
||||
isNodeDefinitionsLoaded: boolean,
|
||||
onNodeEdit: (cell: any, nodeDefinition?: WorkflowNodeDefinition) => void,
|
||||
onEdgeEdit: (edge: Edge) => void
|
||||
) => {
|
||||
const [graph, setGraph] = useState<Graph | null>(null);
|
||||
const [scale, setScale] = useState(1);
|
||||
const graphContainerRef = useRef<HTMLDivElement>(null);
|
||||
const minimapContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 初始化图形
|
||||
useEffect(() => {
|
||||
if (!graphContainerRef.current || !minimapContainerRef.current || !isNodeDefinitionsLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initializer = new GraphInitializer(
|
||||
graphContainerRef.current,
|
||||
minimapContainerRef.current
|
||||
);
|
||||
|
||||
const newGraph = initializer.initializeGraph();
|
||||
|
||||
if (newGraph) {
|
||||
// 注册事件
|
||||
const eventRegistrar = new EventRegistrar(
|
||||
newGraph,
|
||||
nodeDefinitions,
|
||||
onNodeEdit,
|
||||
onEdgeEdit
|
||||
);
|
||||
eventRegistrar.registerAllEvents();
|
||||
|
||||
setGraph(newGraph);
|
||||
}
|
||||
|
||||
return () => {
|
||||
graph?.dispose();
|
||||
};
|
||||
}, [isNodeDefinitionsLoaded, nodeDefinitions, onNodeEdit, onEdgeEdit]);
|
||||
|
||||
// 图形操作方法
|
||||
const graphOperations = {
|
||||
// 撤销操作
|
||||
undo: () => {
|
||||
if (!graph) return;
|
||||
const history = (graph as any).history;
|
||||
if (!history) {
|
||||
console.error('History plugin not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (history.canUndo()) {
|
||||
history.undo();
|
||||
message.success('已撤销');
|
||||
} else {
|
||||
message.info('没有可撤销的操作');
|
||||
}
|
||||
},
|
||||
|
||||
// 重做操作
|
||||
redo: () => {
|
||||
if (!graph) return;
|
||||
const history = (graph as any).history;
|
||||
if (!history) {
|
||||
console.error('History plugin not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
if (history.canRedo()) {
|
||||
history.redo();
|
||||
message.success('已重做');
|
||||
} else {
|
||||
message.info('没有可重做的操作');
|
||||
}
|
||||
},
|
||||
|
||||
// 复制
|
||||
copy: () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length === 0) {
|
||||
message.info('请先选择要复制的节点');
|
||||
return;
|
||||
}
|
||||
graph.copy(cells);
|
||||
message.success('已复制');
|
||||
},
|
||||
|
||||
// 剪切
|
||||
cut: () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length === 0) {
|
||||
message.info('请先选择要剪切的节点');
|
||||
return;
|
||||
}
|
||||
graph.cut(cells);
|
||||
message.success('已剪切');
|
||||
},
|
||||
|
||||
// 粘贴
|
||||
paste: () => {
|
||||
if (!graph) return;
|
||||
if (graph.isClipboardEmpty()) {
|
||||
message.info('剪贴板为空');
|
||||
return;
|
||||
}
|
||||
const cells = graph.paste({offset: 32});
|
||||
graph.cleanSelection();
|
||||
graph.select(cells);
|
||||
message.success('已粘贴');
|
||||
},
|
||||
|
||||
// 缩放
|
||||
zoom: (delta: number) => {
|
||||
if (!graph) return;
|
||||
const currentScale = graph.scale();
|
||||
const newScale = Math.max(0.2, Math.min(2, currentScale.sx + delta));
|
||||
graph.scale(newScale, newScale);
|
||||
setScale(newScale);
|
||||
},
|
||||
|
||||
// 全选
|
||||
selectAll: () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getCells();
|
||||
if (cells.length === 0) {
|
||||
message.info('当前没有可选择的元素');
|
||||
return;
|
||||
}
|
||||
graph.resetSelection();
|
||||
graph.select(cells);
|
||||
// 为选中的元素添加高亮样式
|
||||
cells.forEach(cell => {
|
||||
if (cell.isNode()) {
|
||||
cell.setAttrByPath('body/stroke', '#1890ff');
|
||||
cell.setAttrByPath('body/strokeWidth', 3);
|
||||
cell.setAttrByPath('body/strokeDasharray', '5 5');
|
||||
} else if (cell.isEdge()) {
|
||||
cell.setAttrByPath('line/stroke', '#1890ff');
|
||||
cell.setAttrByPath('line/strokeWidth', 3);
|
||||
cell.setAttrByPath('line/strokeDasharray', '5 5');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// 删除选中元素
|
||||
deleteSelected: () => {
|
||||
if (!graph) return;
|
||||
const cells = graph.getSelectedCells();
|
||||
if (cells.length === 0) {
|
||||
message.info('请先选择要删除的元素');
|
||||
return;
|
||||
}
|
||||
graph.removeCells(cells);
|
||||
},
|
||||
|
||||
// 检查是否可以撤销/重做
|
||||
canUndo: () => {
|
||||
return (graph as any)?.history?.canUndo() || false;
|
||||
},
|
||||
|
||||
canRedo: () => {
|
||||
return (graph as any)?.history?.canRedo() || false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
graph,
|
||||
scale,
|
||||
graphContainerRef,
|
||||
minimapContainerRef,
|
||||
graphOperations
|
||||
};
|
||||
};
|
||||
@ -1,117 +0,0 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Cell, Edge } from '@antv/x6';
|
||||
import { message } from 'antd';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
import type { EdgeCondition } from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 工作流弹窗管理 Hook
|
||||
*/
|
||||
export const useWorkflowModals = () => {
|
||||
const [selectedNode, setSelectedNode] = useState<Cell | null>(null);
|
||||
const [selectedNodeDefinition, setSelectedNodeDefinition] = useState<WorkflowNodeDefinition | null>(null);
|
||||
const [selectedEdge, setSelectedEdge] = useState<Edge | null>(null);
|
||||
const [configModalVisible, setConfigModalVisible] = useState(false);
|
||||
const [expressionModalVisible, setExpressionModalVisible] = useState(false);
|
||||
|
||||
// 处理节点编辑
|
||||
const handleNodeEdit = useCallback((cell: Cell, nodeDefinition?: WorkflowNodeDefinition) => {
|
||||
setSelectedNode(cell);
|
||||
setSelectedNodeDefinition(nodeDefinition || null);
|
||||
setConfigModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// 处理边编辑
|
||||
const handleEdgeEdit = useCallback((edge: Edge) => {
|
||||
setSelectedEdge(edge);
|
||||
setExpressionModalVisible(true);
|
||||
}, []);
|
||||
|
||||
// 处理节点配置更新
|
||||
const handleNodeConfigUpdate = useCallback((formData: any) => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
// 更新节点数据
|
||||
const nodeData = selectedNode.getData() || {};
|
||||
const updatedData = {
|
||||
...nodeData,
|
||||
...formData
|
||||
};
|
||||
selectedNode.setData(updatedData);
|
||||
|
||||
// 更新节点显示名称(如果配置中有nodeName)
|
||||
if (formData.configs?.nodeName) {
|
||||
selectedNode.attr('label/text', formData.configs.nodeName);
|
||||
}
|
||||
|
||||
setConfigModalVisible(false);
|
||||
message.success('节点配置已更新');
|
||||
}, [selectedNode]);
|
||||
|
||||
// 处理条件更新
|
||||
const handleConditionUpdate = useCallback((condition: EdgeCondition) => {
|
||||
if (!selectedEdge) return;
|
||||
|
||||
// 更新边的属性
|
||||
selectedEdge.setProp('condition', condition);
|
||||
|
||||
// 更新边的标签显示
|
||||
const labelText = condition.type === 'EXPRESSION'
|
||||
? condition.expression
|
||||
: '默认路径';
|
||||
|
||||
selectedEdge.setLabels([{
|
||||
attrs: {
|
||||
label: {
|
||||
text: labelText,
|
||||
fill: '#333',
|
||||
fontSize: 12
|
||||
},
|
||||
rect: {
|
||||
fill: '#fff',
|
||||
stroke: '#ccc',
|
||||
rx: 3,
|
||||
ry: 3,
|
||||
padding: 5
|
||||
}
|
||||
},
|
||||
position: {
|
||||
distance: 0.5,
|
||||
offset: 0
|
||||
}
|
||||
}]);
|
||||
|
||||
setExpressionModalVisible(false);
|
||||
setSelectedEdge(null);
|
||||
}, [selectedEdge]);
|
||||
|
||||
// 关闭节点配置弹窗
|
||||
const closeNodeConfigModal = useCallback(() => {
|
||||
setConfigModalVisible(false);
|
||||
setSelectedNode(null);
|
||||
setSelectedNodeDefinition(null);
|
||||
}, []);
|
||||
|
||||
// 关闭条件配置弹窗
|
||||
const closeExpressionModal = useCallback(() => {
|
||||
setExpressionModalVisible(false);
|
||||
setSelectedEdge(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 状态
|
||||
selectedNode,
|
||||
selectedNodeDefinition,
|
||||
selectedEdge,
|
||||
configModalVisible,
|
||||
expressionModalVisible,
|
||||
|
||||
// 处理函数
|
||||
handleNodeEdit,
|
||||
handleEdgeEdit,
|
||||
handleNodeConfigUpdate,
|
||||
handleConditionUpdate,
|
||||
closeNodeConfigModal,
|
||||
closeExpressionModal
|
||||
};
|
||||
};
|
||||
@ -1,171 +1,267 @@
|
||||
.workflow-design {
|
||||
position: relative;
|
||||
height: calc(100vh - 184px);
|
||||
// 工作流设计器 - React Flow版本
|
||||
// 变量定义
|
||||
@header-height: 64px;
|
||||
@toolbar-height: 56px;
|
||||
@sidebar-width: 260px;
|
||||
@container-padding: 24px;
|
||||
|
||||
// 主容器 - 覆盖父容器样式
|
||||
.workflow-design-container {
|
||||
// 使用负边距抵消父容器的padding
|
||||
margin: -@container-padding;
|
||||
// 精确计算高度:视口高度 - header高度
|
||||
height: calc(100vh - @header-height);
|
||||
// 补偿左右padding
|
||||
width: calc(100% + @container-padding * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #f8fafc;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
.header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
// React Flow 样式覆盖
|
||||
.react-flow {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
|
||||
.back-button {
|
||||
margin-right: 16px;
|
||||
&__renderer {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.actions {
|
||||
.ant-space-compact {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
gap: 16px;
|
||||
overflow: hidden; // 防止外层出现滚动条
|
||||
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
border: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
:global {
|
||||
.ant-collapse {
|
||||
border: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
overflow-y: auto; // 只在折叠面板内部显示滚动条
|
||||
|
||||
.ant-collapse-item {
|
||||
border-radius: 0;
|
||||
|
||||
.ant-collapse-header {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.node-item {
|
||||
padding: 8px 16px;
|
||||
margin: 4px 8px;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: move;
|
||||
transition: all 0.3s;
|
||||
background: #ffffff;
|
||||
// 自定义控制按钮样式
|
||||
&__controls {
|
||||
button {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
border-color: #1890ff;
|
||||
background: #f9fafb;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.main-area {
|
||||
// 小地图样式
|
||||
&__minimap {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.workflow-content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
|
||||
.workflow-container {
|
||||
// 节点面板区域
|
||||
.node-panel {
|
||||
width: @sidebar-width;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
// 面板头部
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
// 节点分组
|
||||
.node-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
// 节点项
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
border-color: var(--node-color, #3b82f6);
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
.node-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.node-desc {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用提示
|
||||
.panel-tips {
|
||||
padding: 12px;
|
||||
background: #f1f5f9;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// 画布区域
|
||||
.workflow-canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
|
||||
.workflow-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.minimap-container {
|
||||
// 画布状态面板
|
||||
.canvas-status {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: var(--minimap-width);
|
||||
height: var(--minimap-height);
|
||||
border: 1px solid #f0f0f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global {
|
||||
.node-selected {
|
||||
> rect {
|
||||
stroke: #1890ff;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
> path {
|
||||
stroke: #1890ff;
|
||||
stroke-width: 2px;
|
||||
// 响应式适配
|
||||
@media (max-width: 1024px) {
|
||||
.workflow-content-area .node-panel {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
.x6-node-selected {
|
||||
rect, ellipse {
|
||||
stroke: #1890ff;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.workflow-content-area {
|
||||
flex-direction: column;
|
||||
|
||||
.x6-edge-selected {
|
||||
path {
|
||||
stroke: #1890ff;
|
||||
stroke-width: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 右键菜单样式
|
||||
.x6-context-menu {
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px 0;
|
||||
min-width: 120px;
|
||||
|
||||
&-item {
|
||||
padding: 5px 16px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: all 0.3s;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.node-panel {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏特殊样式
|
||||
.workflow-toolbar {
|
||||
height: @toolbar-height;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
.status-item {
|
||||
background: #f3f4f6;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.react-flow-badge {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,148 +1,617 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { message } from 'antd';
|
||||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||||
|
||||
import WorkflowToolbar from './components/WorkflowToolbar';
|
||||
import WorkflowCanvas from './components/WorkflowCanvas';
|
||||
import NodeConfigDrawer from './components/NodeConfigModal';
|
||||
import ExpressionModal from './components/ExpressionModal';
|
||||
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
|
||||
import { useWorkflowData } from './hooks/useWorkflowData';
|
||||
import { useWorkflowModals } from './hooks/useWorkflowModals';
|
||||
import { useWorkflowDragDrop } from './hooks/useWorkflowDragDrop';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import FlowCanvas from './components/FlowCanvas';
|
||||
import NodeConfigModal from './components/NodeConfigModal';
|
||||
import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal';
|
||||
import type { FlowNode, FlowEdge, FlowNodeData } from './types';
|
||||
import type { WorkflowNodeDefinition } from './nodes/types';
|
||||
import { NodeType } from './types';
|
||||
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
||||
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
||||
import { useHistory } from './hooks/useHistory';
|
||||
|
||||
// 样式
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import './index.less';
|
||||
|
||||
/**
|
||||
* 重构后的工作流设计器主组件
|
||||
*/
|
||||
const WorkflowDesign: React.FC = () => {
|
||||
const WorkflowDesignInner: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 数据管理
|
||||
const {
|
||||
nodeDefinitions,
|
||||
isNodeDefinitionsLoaded,
|
||||
title,
|
||||
loadDefinitionDetail,
|
||||
saveWorkflow
|
||||
} = useWorkflowData();
|
||||
getNodes,
|
||||
setNodes,
|
||||
getEdges,
|
||||
setEdges,
|
||||
screenToFlowPosition,
|
||||
fitView,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
getZoom
|
||||
} = useReactFlow();
|
||||
|
||||
// 弹窗管理
|
||||
const {
|
||||
selectedNode,
|
||||
selectedNodeDefinition,
|
||||
selectedEdge,
|
||||
configModalVisible,
|
||||
expressionModalVisible,
|
||||
handleNodeEdit,
|
||||
handleEdgeEdit,
|
||||
handleNodeConfigUpdate,
|
||||
handleConditionUpdate,
|
||||
closeNodeConfigModal,
|
||||
closeExpressionModal
|
||||
} = useWorkflowModals();
|
||||
const [workflowTitle, setWorkflowTitle] = useState('新建工作流');
|
||||
const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 图形管理
|
||||
const {
|
||||
graph,
|
||||
scale,
|
||||
graphContainerRef,
|
||||
minimapContainerRef,
|
||||
graphOperations
|
||||
} = useWorkflowGraph(
|
||||
nodeDefinitions,
|
||||
isNodeDefinitionsLoaded,
|
||||
handleNodeEdit,
|
||||
handleEdgeEdit
|
||||
);
|
||||
// 当前工作流ID
|
||||
const currentWorkflowId = id ? parseInt(id) : undefined;
|
||||
|
||||
// 拖拽管理
|
||||
const {
|
||||
handleNodeDragStart,
|
||||
handleDrop,
|
||||
handleDragOver
|
||||
} = useWorkflowDragDrop(graph, nodeDefinitions);
|
||||
// 节点配置模态框状态
|
||||
const [configModalVisible, setConfigModalVisible] = useState(false);
|
||||
const [configNode, setConfigNode] = useState<FlowNode | null>(null);
|
||||
|
||||
// 边配置模态框状态
|
||||
const [edgeConfigModalVisible, setEdgeConfigModalVisible] = useState(false);
|
||||
const [configEdge, setConfigEdge] = useState<FlowEdge | null>(null);
|
||||
|
||||
// 保存和加载hooks
|
||||
const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave();
|
||||
const { workflowDefinition, loadWorkflow } = useWorkflowLoad();
|
||||
|
||||
// 历史记录管理
|
||||
const history = useHistory();
|
||||
|
||||
// 剪贴板(用于复制粘贴)
|
||||
const clipboard = useRef<{ nodes: FlowNode[]; edges: FlowEdge[] } | null>(null);
|
||||
|
||||
// 加载工作流数据
|
||||
useEffect(() => {
|
||||
console.log('工作流数据加载检查:', {
|
||||
hasGraph: !!graph,
|
||||
hasId: !!id,
|
||||
id,
|
||||
isNodeDefinitionsLoaded
|
||||
const loadData = async () => {
|
||||
if (currentWorkflowId) {
|
||||
const data = await loadWorkflow(currentWorkflowId);
|
||||
if (data) {
|
||||
setNodes(data.nodes);
|
||||
setEdges(data.edges);
|
||||
setWorkflowTitle(data.definition.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [currentWorkflowId, loadWorkflow, setNodes, setEdges]);
|
||||
|
||||
// 初始化缩放比例
|
||||
useEffect(() => {
|
||||
setCurrentZoom(getZoom());
|
||||
}, [getZoom]);
|
||||
|
||||
// 自动适应视图
|
||||
useEffect(() => {
|
||||
// 延迟执行fitView以确保节点已渲染
|
||||
const timer = setTimeout(() => {
|
||||
fitView({
|
||||
padding: 0.1,
|
||||
duration: 800,
|
||||
minZoom: 1.0, // 最小缩放100%
|
||||
maxZoom: 1.0 // 最大缩放100%,确保默认100%
|
||||
});
|
||||
// 更新zoom显示
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||||
}, 100);
|
||||
|
||||
if (!id) {
|
||||
console.error('工作流ID缺失,无法加载定义数据');
|
||||
return;
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [fitView, getZoom]);
|
||||
|
||||
if (graph && id && isNodeDefinitionsLoaded) {
|
||||
console.log('开始加载工作流定义详情:', id);
|
||||
loadDefinitionDetail(graph, id);
|
||||
// 初始化示例节点 - 优化位置和布局
|
||||
const initialNodes: FlowNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'START_EVENT',
|
||||
position: { x: 250, y: 50 },
|
||||
data: {
|
||||
label: '开始',
|
||||
nodeType: NodeType.START_EVENT,
|
||||
category: 'EVENT' as any,
|
||||
icon: '▶️',
|
||||
color: '#10b981'
|
||||
}
|
||||
}, [graph, id, isNodeDefinitionsLoaded, loadDefinitionDetail]);
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'USER_TASK',
|
||||
position: { x: 200, y: 150 },
|
||||
data: {
|
||||
label: '用户审批',
|
||||
nodeType: NodeType.USER_TASK,
|
||||
category: 'TASK' as any,
|
||||
icon: '👤',
|
||||
color: '#6366f1'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'END_EVENT',
|
||||
position: { x: 250, y: 250 },
|
||||
data: {
|
||||
label: '结束',
|
||||
nodeType: NodeType.END_EVENT,
|
||||
category: 'EVENT' as any,
|
||||
icon: '⏹️',
|
||||
color: '#ef4444'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const initialEdges: FlowEdge[] = [
|
||||
{
|
||||
id: 'e1-2',
|
||||
source: '1',
|
||||
target: '2',
|
||||
type: 'default',
|
||||
animated: true,
|
||||
data: {
|
||||
label: '提交',
|
||||
condition: {
|
||||
type: 'DEFAULT',
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'e2-3',
|
||||
source: '2',
|
||||
target: '3',
|
||||
type: 'default',
|
||||
animated: true,
|
||||
data: {
|
||||
label: '通过',
|
||||
condition: {
|
||||
type: 'DEFAULT',
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// 工具栏事件处理
|
||||
const handleBack = () => navigate('/workflow/definition');
|
||||
const handleSave = () => {
|
||||
if (!graph) {
|
||||
console.error('Graph 实例不存在');
|
||||
const handleSave = useCallback(async () => {
|
||||
const nodes = getNodes() as FlowNode[];
|
||||
const edges = getEdges() as FlowEdge[];
|
||||
|
||||
const success = await saveWorkflow({
|
||||
nodes,
|
||||
edges,
|
||||
workflowId: currentWorkflowId,
|
||||
name: workflowTitle,
|
||||
description: workflowDefinition?.description || '',
|
||||
definitionData: workflowDefinition // 传递原始定义数据
|
||||
});
|
||||
|
||||
if (success) {
|
||||
console.log('保存工作流成功:', { nodes, edges });
|
||||
}
|
||||
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate('/workflow/definition');
|
||||
}, [navigate]);
|
||||
|
||||
// 撤销操作
|
||||
const handleUndo = useCallback(() => {
|
||||
const state = history.undo();
|
||||
if (state) {
|
||||
history.pauseRecording();
|
||||
setNodes(state.nodes);
|
||||
setEdges(state.edges);
|
||||
setTimeout(() => history.resumeRecording(), 100);
|
||||
message.success('已撤销');
|
||||
} else {
|
||||
message.info('没有可撤销的操作');
|
||||
}
|
||||
}, [history, setNodes, setEdges]);
|
||||
|
||||
// 重做操作
|
||||
const handleRedo = useCallback(() => {
|
||||
const state = history.redo();
|
||||
if (state) {
|
||||
history.pauseRecording();
|
||||
setNodes(state.nodes);
|
||||
setEdges(state.edges);
|
||||
setTimeout(() => history.resumeRecording(), 100);
|
||||
message.success('已重做');
|
||||
} else {
|
||||
message.info('没有可重做的操作');
|
||||
}
|
||||
}, [history, setNodes, setEdges]);
|
||||
|
||||
// 复制选中的节点和边
|
||||
const handleCopy = useCallback(() => {
|
||||
const selectedNodes = getNodes().filter(node => node.selected);
|
||||
const selectedEdges = getEdges().filter(edge => {
|
||||
// 只复制两端都被选中的边
|
||||
return selectedNodes.some(n => n.id === edge.source) &&
|
||||
selectedNodes.some(n => n.id === edge.target);
|
||||
});
|
||||
|
||||
if (selectedNodes.length > 0) {
|
||||
clipboard.current = {
|
||||
nodes: JSON.parse(JSON.stringify(selectedNodes)),
|
||||
edges: JSON.parse(JSON.stringify(selectedEdges))
|
||||
};
|
||||
message.success(`已复制 ${selectedNodes.length} 个节点`);
|
||||
} else {
|
||||
message.warning('请先选择要复制的节点');
|
||||
}
|
||||
}, [getNodes, getEdges]);
|
||||
|
||||
// 粘贴节点
|
||||
const handlePaste = useCallback(() => {
|
||||
if (!clipboard.current || clipboard.current.nodes.length === 0) {
|
||||
message.info('剪贴板为空');
|
||||
return;
|
||||
}
|
||||
saveWorkflow(graph);
|
||||
|
||||
const { nodes: copiedNodes, edges: copiedEdges } = clipboard.current;
|
||||
const offset = 50; // 粘贴偏移量
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
// 创建新节点(带偏移)
|
||||
const newNodes = copiedNodes.map(node => {
|
||||
const newId = `${node.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
idMap.set(node.id, newId);
|
||||
|
||||
return {
|
||||
...node,
|
||||
id: newId,
|
||||
position: {
|
||||
x: node.position.x + offset,
|
||||
y: node.position.y + offset
|
||||
},
|
||||
selected: true
|
||||
};
|
||||
const handleZoomIn = () => graphOperations.zoom(0.1);
|
||||
const handleZoomOut = () => graphOperations.zoom(-0.1);
|
||||
});
|
||||
|
||||
// 创建新边(更新source和target为新ID)
|
||||
const newEdges = copiedEdges.map(edge => {
|
||||
const newSource = idMap.get(edge.source);
|
||||
const newTarget = idMap.get(edge.target);
|
||||
|
||||
if (!newSource || !newTarget) return null;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: `e${newSource}-${newTarget}`,
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
selected: true
|
||||
};
|
||||
}).filter(edge => edge !== null) as FlowEdge[];
|
||||
|
||||
// 取消其他元素的选中状态
|
||||
setNodes(nodes => [
|
||||
...nodes.map(n => ({ ...n, selected: false })),
|
||||
...newNodes
|
||||
]);
|
||||
setEdges(edges => [
|
||||
...edges.map(e => ({ ...e, selected: false })),
|
||||
...newEdges
|
||||
]);
|
||||
|
||||
message.success(`已粘贴 ${newNodes.length} 个节点`);
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const selectedNodes = getNodes().filter(node => node.selected);
|
||||
const selectedEdges = getEdges().filter(edge => edge.selected);
|
||||
|
||||
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
||||
setNodes(nodes => nodes.filter(node => !node.selected));
|
||||
setEdges(edges => edges.filter(edge => !edge.selected));
|
||||
message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`);
|
||||
markUnsaved();
|
||||
} else {
|
||||
message.warning('请先选择要删除的元素');
|
||||
}
|
||||
}, [getNodes, getEdges, setNodes, setEdges, markUnsaved]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||||
setEdges(edges => edges.map(edge => ({ ...edge, selected: true })));
|
||||
message.info('已全选所有元素');
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView({ padding: 0.2, duration: 800 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||||
}, [fitView, getZoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn({ duration: 300 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||||
}, [zoomIn, getZoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut({ duration: 300 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||||
}, [zoomOut, getZoom]);
|
||||
|
||||
// 处理节点拖拽放置 - 使用官方推荐的screenToFlowPosition方法
|
||||
const handleDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const dragData = event.dataTransfer.getData('application/reactflow');
|
||||
if (!dragData) return;
|
||||
|
||||
try {
|
||||
const { nodeType, nodeDefinition }: { nodeType: string; nodeDefinition: WorkflowNodeDefinition } = JSON.parse(dragData);
|
||||
|
||||
// 根据React Flow官方文档,screenToFlowPosition会自动处理所有边界计算
|
||||
// 不需要手动减去容器边界!
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: FlowNode = {
|
||||
id: `${nodeType}-${Date.now()}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {
|
||||
label: nodeDefinition.nodeName,
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
category: nodeDefinition.category,
|
||||
icon: nodeDefinition.uiConfig.style.icon,
|
||||
color: nodeDefinition.uiConfig.style.fill,
|
||||
// 保存原始节点定义引用,用于配置
|
||||
nodeDefinition,
|
||||
// 初始化配置数据
|
||||
configs: {
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description
|
||||
},
|
||||
inputMapping: {},
|
||||
outputMapping: {}
|
||||
}
|
||||
};
|
||||
|
||||
setNodes(nodes => [...nodes, newNode]);
|
||||
message.success(`已添加 ${nodeDefinition.nodeName} 节点`);
|
||||
} catch (error) {
|
||||
console.error('解析拖拽数据失败:', error);
|
||||
message.error('添加节点失败');
|
||||
}
|
||||
}, [screenToFlowPosition, setNodes]);
|
||||
|
||||
// 处理节点双击 - 打开配置面板
|
||||
const handleNodeClick = useCallback((event: React.MouseEvent, node: FlowNode) => {
|
||||
// 只处理双击事件
|
||||
if (event.detail === 2) {
|
||||
console.log('双击节点,打开配置:', node);
|
||||
setConfigNode(node);
|
||||
setConfigModalVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理边双击 - 打开条件配置弹窗
|
||||
const handleEdgeClick = useCallback((event: React.MouseEvent, edge: FlowEdge) => {
|
||||
if (event.detail === 2) {
|
||||
console.log('双击边,打开配置:', edge);
|
||||
setConfigEdge(edge);
|
||||
setEdgeConfigModalVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理节点配置更新
|
||||
const handleNodeConfigUpdate = useCallback((nodeId: string, updatedData: Partial<FlowNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
...updatedData,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
markUnsaved(); // 标记有未保存更改
|
||||
}, [setNodes, markUnsaved]);
|
||||
|
||||
// 关闭节点配置模态框
|
||||
const handleCloseConfigModal = useCallback(() => {
|
||||
setConfigModalVisible(false);
|
||||
setConfigNode(null);
|
||||
}, []);
|
||||
|
||||
// 处理边条件更新
|
||||
const handleEdgeConditionUpdate = useCallback((edgeId: string, condition: EdgeCondition) => {
|
||||
setEdges((edges) =>
|
||||
edges.map((edge) => {
|
||||
if (edge.id === edgeId) {
|
||||
// 根据条件类型生成显示文本
|
||||
const label = condition.type === 'EXPRESSION'
|
||||
? condition.expression
|
||||
: '默认路径';
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
condition,
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
})
|
||||
);
|
||||
markUnsaved(); // 标记有未保存更改
|
||||
message.success('边条件配置已更新');
|
||||
setEdgeConfigModalVisible(false);
|
||||
setConfigEdge(null);
|
||||
}, [setEdges, markUnsaved]);
|
||||
|
||||
// 关闭边配置模态框
|
||||
const handleCloseEdgeConfigModal = useCallback(() => {
|
||||
setEdgeConfigModalVisible(false);
|
||||
setConfigEdge(null);
|
||||
}, []);
|
||||
|
||||
// 监听视图变化(缩放、平移等)
|
||||
const handleViewportChange = useCallback(() => {
|
||||
const zoom = getZoom();
|
||||
setCurrentZoom(zoom);
|
||||
}, [getZoom]);
|
||||
|
||||
// 监听节点和边的变化,记录到历史
|
||||
useEffect(() => {
|
||||
const nodes = getNodes() as FlowNode[];
|
||||
const edges = getEdges() as FlowEdge[];
|
||||
|
||||
// 只在有节点或边时记录(避免空状态)
|
||||
if (nodes.length > 0 || edges.length > 0) {
|
||||
history.record(nodes, edges);
|
||||
}
|
||||
}, [getNodes, getEdges, history]);
|
||||
|
||||
// 键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 检查焦点是否在输入框、文本域或可编辑元素内
|
||||
const target = e.target as HTMLElement;
|
||||
const tagName = target.tagName?.toUpperCase();
|
||||
const isInputElement = tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable ||
|
||||
target.getAttribute('contenteditable') === 'true';
|
||||
const isInDrawer = target.closest('.ant-drawer-body') !== null;
|
||||
const isInModal = target.closest('.ant-modal') !== null;
|
||||
|
||||
// 在抽屉或模态框内,且在输入元素中时,允许原生行为
|
||||
const shouldSkipShortcut = isInputElement || isInDrawer || isInModal;
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Ctrl+Z / Cmd+Z - 撤销(仅在画布区域)
|
||||
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
}
|
||||
}
|
||||
// Ctrl+Shift+Z / Cmd+Shift+Z - 重做(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'z' && e.shiftKey) {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
}
|
||||
// Ctrl+C / Cmd+C - 复制节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'c') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleCopy();
|
||||
}
|
||||
}
|
||||
// Ctrl+V / Cmd+V - 粘贴节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'v') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
}
|
||||
}
|
||||
// Ctrl+A / Cmd+A - 全选节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'a') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleSelectAll();
|
||||
}
|
||||
}
|
||||
// Delete / Backspace - 删除节点(仅在画布区域)
|
||||
else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]);
|
||||
|
||||
return (
|
||||
<div className="workflow-design">
|
||||
<div
|
||||
className="workflow-design-container"
|
||||
style={{
|
||||
// 确保覆盖父容器的overflow设置
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<WorkflowToolbar
|
||||
title={title}
|
||||
scale={scale}
|
||||
canUndo={graphOperations.canUndo()}
|
||||
canRedo={graphOperations.canRedo()}
|
||||
onBack={handleBack}
|
||||
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
|
||||
onSave={handleSave}
|
||||
onUndo={graphOperations.undo}
|
||||
onRedo={graphOperations.redo}
|
||||
onCopy={graphOperations.copy}
|
||||
onCut={graphOperations.cut}
|
||||
onPaste={graphOperations.paste}
|
||||
onBack={handleBack}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onSelectAll={graphOperations.selectAll}
|
||||
onDelete={graphOperations.deleteSelected}
|
||||
onFitView={handleFitView}
|
||||
canUndo={history.canUndo}
|
||||
canRedo={history.canRedo}
|
||||
zoom={currentZoom}
|
||||
/>
|
||||
|
||||
<WorkflowCanvas
|
||||
graphContainerRef={graphContainerRef}
|
||||
minimapContainerRef={minimapContainerRef}
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
{/* 主要内容区域 */}
|
||||
<div className="workflow-content-area">
|
||||
{/* 节点面板 */}
|
||||
<NodePanel />
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="workflow-canvas-area"
|
||||
>
|
||||
<FlowCanvas
|
||||
initialNodes={initialNodes}
|
||||
initialEdges={initialEdges}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onViewportChange={handleViewportChange}
|
||||
className="workflow-canvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{configModalVisible && selectedNode && selectedNodeDefinition && (
|
||||
<NodeConfigDrawer
|
||||
{/* 节点配置弹窗 */}
|
||||
<NodeConfigModal
|
||||
visible={configModalVisible}
|
||||
node={selectedNode}
|
||||
nodeDefinition={selectedNodeDefinition}
|
||||
onCancel={closeNodeConfigModal}
|
||||
node={configNode}
|
||||
onCancel={handleCloseConfigModal}
|
||||
onOk={handleNodeConfigUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedEdge && (
|
||||
<ExpressionModal
|
||||
visible={expressionModalVisible}
|
||||
edge={selectedEdge}
|
||||
onOk={handleConditionUpdate}
|
||||
onCancel={closeExpressionModal}
|
||||
{/* 边条件配置弹窗 */}
|
||||
<EdgeConfigModal
|
||||
visible={edgeConfigModalVisible}
|
||||
edge={configEdge}
|
||||
onOk={handleEdgeConditionUpdate}
|
||||
onCancel={handleCloseEdgeConfigModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkflowDesign: React.FC = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowDesignInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowDesign;
|
||||
|
||||
@ -1,234 +0,0 @@
|
||||
import { ConfigurableNodeDefinition, NodeType, NodeCategory } from '../types';
|
||||
|
||||
/**
|
||||
* 部署任务节点定义
|
||||
* 可配置节点,支持配置、输入映射、输出映射
|
||||
*/
|
||||
export const DeployNode: ConfigurableNodeDefinition = {
|
||||
nodeCode: "DEPLOY_NODE",
|
||||
nodeName: "构建任务",
|
||||
nodeType: NodeType.DEPLOY_NODE,
|
||||
category: NodeCategory.TASK,
|
||||
description: "执行应用构建和部署任务",
|
||||
|
||||
// UI 配置 - 节点在画布上的显示样式(现代化设计)
|
||||
uiConfig: {
|
||||
shape: 'rect',
|
||||
size: {
|
||||
width: 160,
|
||||
height: 80
|
||||
},
|
||||
style: {
|
||||
fill: '#1890ff',
|
||||
stroke: '#0050b3',
|
||||
strokeWidth: 2,
|
||||
icon: 'build',
|
||||
iconColor: '#fff'
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
// 输入端口 - 现代化样式
|
||||
in: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 7,
|
||||
fill: '#ffffff',
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2.5,
|
||||
// 现代化端口样式
|
||||
filter: 'drop-shadow(0 2px 4px rgba(59, 130, 246, 0.3))',
|
||||
// 端口悬浮效果
|
||||
':hover': {
|
||||
r: 8,
|
||||
fill: '#dbeafe',
|
||||
stroke: '#2563eb'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// 输出端口 - 现代化样式
|
||||
out: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 7,
|
||||
fill: '#ffffff',
|
||||
stroke: '#3b82f6',
|
||||
strokeWidth: 2.5,
|
||||
// 现代化端口样式
|
||||
filter: 'drop-shadow(0 2px 4px rgba(59, 130, 246, 0.3))',
|
||||
// 端口悬浮效果
|
||||
':hover': {
|
||||
r: 8,
|
||||
fill: '#dbeafe',
|
||||
stroke: '#2563eb'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 基本配置Schema - 设计时用于生成表单,保存时转为key/value(包含基本信息+节点配置)
|
||||
configSchema: {
|
||||
type: "object",
|
||||
title: "基本配置",
|
||||
description: "节点的基本信息和构建任务的配置参数",
|
||||
properties: {
|
||||
// 基本信息
|
||||
nodeName: {
|
||||
type: "string",
|
||||
title: "节点名称",
|
||||
description: "节点在流程图中显示的名称",
|
||||
default: "构建任务"
|
||||
},
|
||||
nodeCode: {
|
||||
type: "string",
|
||||
title: "节点编码",
|
||||
description: "节点的唯一标识符",
|
||||
default: "DEPLOY_NODE"
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
title: "节点描述",
|
||||
description: "节点的详细说明",
|
||||
default: "执行应用构建和部署任务"
|
||||
},
|
||||
// 节点配置
|
||||
buildCommand: {
|
||||
type: "string",
|
||||
title: "构建命令",
|
||||
description: "执行构建的命令",
|
||||
default: "npm run build"
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
title: "超时时间(秒)",
|
||||
description: "构建超时时间",
|
||||
default: 300,
|
||||
minimum: 30,
|
||||
maximum: 3600
|
||||
},
|
||||
retryCount: {
|
||||
type: "number",
|
||||
title: "重试次数",
|
||||
description: "构建失败时的重试次数",
|
||||
default: 2,
|
||||
minimum: 0,
|
||||
maximum: 5
|
||||
},
|
||||
environment: {
|
||||
type: "string",
|
||||
title: "运行环境",
|
||||
description: "构建运行的环境",
|
||||
enum: ["development", "staging", "production"],
|
||||
default: "production"
|
||||
},
|
||||
dockerImage: {
|
||||
type: "string",
|
||||
title: "Docker镜像",
|
||||
description: "构建使用的Docker镜像",
|
||||
default: "node:18-alpine"
|
||||
},
|
||||
workingDirectory: {
|
||||
type: "string",
|
||||
title: "工作目录",
|
||||
description: "构建的工作目录",
|
||||
default: "/app"
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode", "buildCommand", "timeout"]
|
||||
},
|
||||
|
||||
// 输入映射Schema - 定义从上游节点接收的数据
|
||||
inputMappingSchema: {
|
||||
type: "object",
|
||||
title: "输入映射",
|
||||
description: "从上游节点接收的数据映射配置",
|
||||
properties: {
|
||||
sourceCodePath: {
|
||||
type: "string",
|
||||
title: "源代码路径",
|
||||
description: "源代码在存储中的路径",
|
||||
default: "${upstream.outputPath}"
|
||||
},
|
||||
buildArgs: {
|
||||
type: "array",
|
||||
title: "构建参数",
|
||||
description: "额外的构建参数列表",
|
||||
items: {
|
||||
type: "string"
|
||||
},
|
||||
default: []
|
||||
},
|
||||
envVariables: {
|
||||
type: "object",
|
||||
title: "环境变量",
|
||||
description: "构建时的环境变量",
|
||||
properties: {},
|
||||
additionalProperties: true
|
||||
},
|
||||
dependencies: {
|
||||
type: "string",
|
||||
title: "依赖文件",
|
||||
description: "依赖配置文件路径",
|
||||
default: "${upstream.dependenciesFile}"
|
||||
}
|
||||
},
|
||||
required: ["sourceCodePath"]
|
||||
},
|
||||
|
||||
// 输出映射Schema - 定义传递给下游节点的数据
|
||||
outputMappingSchema: {
|
||||
type: "object",
|
||||
title: "输出映射",
|
||||
description: "传递给下游节点的数据映射配置",
|
||||
properties: {
|
||||
buildArtifactPath: {
|
||||
type: "string",
|
||||
title: "构建产物路径",
|
||||
description: "构建完成后的产物存储路径",
|
||||
default: "/artifacts/${buildId}"
|
||||
},
|
||||
buildLog: {
|
||||
type: "string",
|
||||
title: "构建日志",
|
||||
description: "构建过程的日志文件路径",
|
||||
default: "/logs/build-${buildId}.log"
|
||||
},
|
||||
buildStatus: {
|
||||
type: "string",
|
||||
title: "构建状态",
|
||||
description: "构建完成状态",
|
||||
enum: ["SUCCESS", "FAILED", "TIMEOUT"],
|
||||
default: "SUCCESS"
|
||||
},
|
||||
buildTime: {
|
||||
type: "number",
|
||||
title: "构建耗时",
|
||||
description: "构建耗时(秒)",
|
||||
default: 0
|
||||
},
|
||||
dockerImageTag: {
|
||||
type: "string",
|
||||
title: "Docker镜像标签",
|
||||
description: "构建生成的Docker镜像标签",
|
||||
default: "${imageRegistry}/${projectName}:${buildId}"
|
||||
},
|
||||
metadata: {
|
||||
type: "object",
|
||||
title: "构建元数据",
|
||||
description: "构建过程中的元数据信息",
|
||||
properties: {
|
||||
buildId: { type: "string" },
|
||||
buildTime: { type: "string" },
|
||||
gitCommit: { type: "string" },
|
||||
gitBranch: { type: "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["buildArtifactPath", "buildStatus"]
|
||||
}
|
||||
};
|
||||
@ -1,72 +0,0 @@
|
||||
import { BaseNodeDefinition, NodeType, NodeCategory } from '../types';
|
||||
|
||||
/**
|
||||
* 结束事件节点定义
|
||||
* 简单节点,无需额外配置
|
||||
*/
|
||||
export const EndEventNode: BaseNodeDefinition = {
|
||||
nodeCode: "END_EVENT",
|
||||
nodeName: "结束",
|
||||
nodeType: NodeType.END_EVENT,
|
||||
category: NodeCategory.EVENT,
|
||||
description: "工作流的结束节点",
|
||||
|
||||
// UI 配置 - 节点在画布上的显示样式(现代化设计)
|
||||
uiConfig: {
|
||||
shape: 'ellipse',
|
||||
size: {
|
||||
width: 80,
|
||||
height: 50
|
||||
},
|
||||
style: {
|
||||
fill: '#f5222d',
|
||||
stroke: '#cf1322',
|
||||
strokeWidth: 2,
|
||||
icon: 'stop',
|
||||
iconColor: '#fff'
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
// 结束节点只有输入端口 - 现代化样式
|
||||
in: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 4,
|
||||
fill: '#fff',
|
||||
stroke: '#f5222d'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 基本配置Schema - 用于生成"基本配置"TAB(包含基本信息)
|
||||
configSchema: {
|
||||
type: "object",
|
||||
title: "基本配置",
|
||||
description: "节点的基本配置信息",
|
||||
properties: {
|
||||
nodeName: {
|
||||
type: "string",
|
||||
title: "节点名称",
|
||||
description: "节点在流程图中显示的名称",
|
||||
default: "结束"
|
||||
},
|
||||
nodeCode: {
|
||||
type: "string",
|
||||
title: "节点编码",
|
||||
description: "节点的唯一标识符",
|
||||
default: "END_EVENT"
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
title: "节点描述",
|
||||
description: "节点的详细说明",
|
||||
default: "工作流的结束节点"
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode"]
|
||||
}
|
||||
};
|
||||
@ -1,271 +0,0 @@
|
||||
import { ConfigurableNodeDefinition, NodeType, NodeCategory } from '../types';
|
||||
|
||||
/**
|
||||
* Jenkins构建节点定义
|
||||
* 可配置节点,支持配置、输入映射、输出映射
|
||||
*/
|
||||
export const JenkinsBuildNode: ConfigurableNodeDefinition = {
|
||||
nodeCode: "JENKINS_BUILD",
|
||||
nodeName: "Jenkins构建",
|
||||
nodeType: NodeType.JENKINS_BUILD,
|
||||
category: NodeCategory.TASK,
|
||||
description: "通过Jenkins执行构建任务",
|
||||
|
||||
// UI 配置 - 节点在画布上的显示样式
|
||||
uiConfig: {
|
||||
shape: 'rect',
|
||||
size: {
|
||||
width: 160,
|
||||
height: 80
|
||||
},
|
||||
style: {
|
||||
fill: '#52c41a',
|
||||
stroke: '#389e08',
|
||||
strokeWidth: 2,
|
||||
icon: 'jenkins',
|
||||
iconColor: '#fff'
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
// 输入端口
|
||||
in: {
|
||||
position: 'left',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 4,
|
||||
fill: '#fff',
|
||||
stroke: '#52c41a'
|
||||
}
|
||||
}
|
||||
},
|
||||
// 输出端口
|
||||
out: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 4,
|
||||
fill: '#fff',
|
||||
stroke: '#52c41a'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 基本配置Schema - 设计时用于生成表单,保存时转为key/value(包含基本信息和Jenkins配置)
|
||||
configSchema: {
|
||||
type: "object",
|
||||
title: "基本配置",
|
||||
description: "节点的基本信息和Jenkins构建配置参数",
|
||||
properties: {
|
||||
nodeName: {
|
||||
type: "string",
|
||||
title: "节点名称",
|
||||
description: "节点在流程图中显示的名称",
|
||||
default: "Jenkins构建"
|
||||
},
|
||||
nodeCode: {
|
||||
type: "string",
|
||||
title: "节点编码",
|
||||
description: "节点的唯一标识符",
|
||||
default: "JENKINS_BUILD"
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
title: "节点描述",
|
||||
description: "节点的详细说明",
|
||||
default: "通过Jenkins执行构建任务"
|
||||
},
|
||||
jenkinsUrl: {
|
||||
type: "string",
|
||||
title: "Jenkins服务器地址",
|
||||
description: "Jenkins服务器的完整URL地址",
|
||||
default: "http://jenkins.example.com:8080",
|
||||
format: "uri"
|
||||
},
|
||||
jobName: {
|
||||
type: "string",
|
||||
title: "Job名称",
|
||||
description: "要执行的Jenkins Job名称",
|
||||
default: ""
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
title: "用户名",
|
||||
description: "Jenkins认证用户名",
|
||||
default: ""
|
||||
},
|
||||
apiToken: {
|
||||
type: "string",
|
||||
title: "API Token",
|
||||
description: "Jenkins API Token或密码",
|
||||
default: "",
|
||||
format: "password"
|
||||
},
|
||||
timeout: {
|
||||
type: "number",
|
||||
title: "超时时间(秒)",
|
||||
description: "构建超时时间",
|
||||
default: 600,
|
||||
minimum: 60,
|
||||
maximum: 7200
|
||||
},
|
||||
waitForCompletion: {
|
||||
type: "boolean",
|
||||
title: "等待构建完成",
|
||||
description: "是否等待Jenkins构建完成",
|
||||
default: true
|
||||
},
|
||||
retryCount: {
|
||||
type: "number",
|
||||
title: "重试次数",
|
||||
description: "构建失败时的重试次数",
|
||||
default: 1,
|
||||
minimum: 0,
|
||||
maximum: 5
|
||||
},
|
||||
branchName: {
|
||||
type: "string",
|
||||
title: "分支名称",
|
||||
description: "要构建的Git分支名称",
|
||||
default: "main"
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode", "jenkinsUrl", "jobName", "username", "apiToken"]
|
||||
},
|
||||
|
||||
// 输入映射Schema - 定义从上游节点接收的数据
|
||||
inputMappingSchema: {
|
||||
type: "object",
|
||||
title: "输入映射",
|
||||
description: "从上游节点接收的数据映射配置",
|
||||
properties: {
|
||||
sourceCodeUrl: {
|
||||
type: "string",
|
||||
title: "源代码仓库地址",
|
||||
description: "Git仓库的URL地址",
|
||||
default: "${upstream.gitUrl}"
|
||||
},
|
||||
buildParameters: {
|
||||
type: "object",
|
||||
title: "构建参数",
|
||||
description: "传递给Jenkins Job的构建参数",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
default: {}
|
||||
},
|
||||
environmentVariables: {
|
||||
type: "object",
|
||||
title: "环境变量",
|
||||
description: "构建时的环境变量",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
default: {}
|
||||
},
|
||||
commitId: {
|
||||
type: "string",
|
||||
title: "提交ID",
|
||||
description: "要构建的Git提交ID",
|
||||
default: "${upstream.commitId}"
|
||||
},
|
||||
projectName: {
|
||||
type: "string",
|
||||
title: "项目名称",
|
||||
description: "项目的名称标识",
|
||||
default: "${upstream.projectName}"
|
||||
},
|
||||
buildVersion: {
|
||||
type: "string",
|
||||
title: "构建版本",
|
||||
description: "构建版本号",
|
||||
default: "${upstream.version}"
|
||||
}
|
||||
},
|
||||
required: ["sourceCodeUrl", "buildParameters"]
|
||||
},
|
||||
|
||||
// 输出映射Schema - 定义传递给下游节点的数据
|
||||
outputMappingSchema: {
|
||||
type: "object",
|
||||
title: "输出映射",
|
||||
description: "传递给下游节点的数据映射配置",
|
||||
properties: {
|
||||
buildId: {
|
||||
type: "string",
|
||||
title: "构建ID",
|
||||
description: "Jenkins构建的唯一标识符",
|
||||
default: "${jenkins.buildNumber}"
|
||||
},
|
||||
buildStatus: {
|
||||
type: "string",
|
||||
title: "构建状态",
|
||||
description: "构建完成状态",
|
||||
enum: ["SUCCESS", "FAILURE", "UNSTABLE", "ABORTED", "NOT_BUILT"],
|
||||
default: "SUCCESS"
|
||||
},
|
||||
buildUrl: {
|
||||
type: "string",
|
||||
title: "构建URL",
|
||||
description: "Jenkins构建页面的URL",
|
||||
default: "${jenkins.buildUrl}"
|
||||
},
|
||||
buildDuration: {
|
||||
type: "number",
|
||||
title: "构建耗时",
|
||||
description: "构建耗时(毫秒)",
|
||||
default: 0
|
||||
},
|
||||
artifactsUrl: {
|
||||
type: "string",
|
||||
title: "构建产物URL",
|
||||
description: "构建产物的下载地址",
|
||||
default: "${jenkins.artifactsUrl}"
|
||||
},
|
||||
consoleLog: {
|
||||
type: "string",
|
||||
title: "控制台日志",
|
||||
description: "构建的控制台输出日志",
|
||||
default: "${jenkins.consoleText}"
|
||||
},
|
||||
testResults: {
|
||||
type: "object",
|
||||
title: "测试结果",
|
||||
description: "构建中的测试结果统计",
|
||||
properties: {
|
||||
totalCount: { type: "number", title: "总测试数", default: 0 },
|
||||
passCount: { type: "number", title: "通过数", default: 0 },
|
||||
failCount: { type: "number", title: "失败数", default: 0 },
|
||||
skipCount: { type: "number", title: "跳过数", default: 0 }
|
||||
},
|
||||
default: {
|
||||
totalCount: 0,
|
||||
passCount: 0,
|
||||
failCount: 0,
|
||||
skipCount: 0
|
||||
}
|
||||
},
|
||||
buildParameters: {
|
||||
type: "object",
|
||||
title: "构建参数",
|
||||
description: "实际使用的构建参数",
|
||||
properties: {},
|
||||
additionalProperties: true,
|
||||
default: {}
|
||||
},
|
||||
commitInfo: {
|
||||
type: "object",
|
||||
title: "提交信息",
|
||||
description: "构建对应的Git提交信息",
|
||||
properties: {
|
||||
commitId: { type: "string", title: "提交ID" },
|
||||
commitMessage: { type: "string", title: "提交消息" },
|
||||
commitAuthor: { type: "string", title: "提交作者" },
|
||||
commitTimestamp: { type: "string", title: "提交时间" }
|
||||
},
|
||||
default: {}
|
||||
}
|
||||
},
|
||||
required: ["buildId", "buildStatus", "buildUrl"]
|
||||
}
|
||||
};
|
||||
@ -1,72 +0,0 @@
|
||||
import {BaseNodeDefinition, NodeType, NodeCategory} from '../types';
|
||||
|
||||
/**
|
||||
* 开始事件节点定义
|
||||
* 简单节点,无需额外配置
|
||||
*/
|
||||
export const StartEventNode: BaseNodeDefinition = {
|
||||
nodeCode: "START_EVENT",
|
||||
nodeName: "开始",
|
||||
nodeType: NodeType.START_EVENT,
|
||||
category: NodeCategory.EVENT,
|
||||
description: "工作流的起始节点",
|
||||
|
||||
// UI 配置 - 节点在画布上的显示样式(现代化设计)
|
||||
uiConfig: {
|
||||
shape: 'ellipse',
|
||||
size: {
|
||||
width: 80,
|
||||
height: 50
|
||||
},
|
||||
style: {
|
||||
fill: '#52c41a',
|
||||
stroke: '#389e08',
|
||||
strokeWidth: 2,
|
||||
icon: 'play-circle',
|
||||
iconColor: '#fff'
|
||||
},
|
||||
ports: {
|
||||
groups: {
|
||||
// 开始节点只有输出端口 - 现代化样式
|
||||
out: {
|
||||
position: 'right',
|
||||
attrs: {
|
||||
circle: {
|
||||
r: 4,
|
||||
fill: '#fff',
|
||||
stroke: '#52c41a'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// 基本配置Schema - 用于生成"基本配置"TAB(包含基本信息)
|
||||
configSchema: {
|
||||
type: "object",
|
||||
title: "基本配置",
|
||||
description: "节点的基本配置信息",
|
||||
properties: {
|
||||
nodeName: {
|
||||
type: "string",
|
||||
title: "节点名称",
|
||||
description: "节点在流程图中显示的名称",
|
||||
default: "开始"
|
||||
},
|
||||
nodeCode: {
|
||||
type: "string",
|
||||
title: "节点编码",
|
||||
description: "节点的唯一标识符",
|
||||
default: "START_EVENT"
|
||||
},
|
||||
description: {
|
||||
type: "string",
|
||||
title: "节点描述",
|
||||
description: "节点的详细说明",
|
||||
default: "工作流的起始节点"
|
||||
}
|
||||
},
|
||||
required: ["nodeName", "nodeCode"]
|
||||
}
|
||||
};
|
||||
@ -1,26 +0,0 @@
|
||||
import {WorkflowNodeDefinition} from '../types';
|
||||
import {DeployNode} from './DeployNode';
|
||||
import {JenkinsBuildNode} from './JenkinsBuildNode';
|
||||
import {StartEventNode} from './StartEventNode';
|
||||
import {EndEventNode} from './EndEventNode';
|
||||
|
||||
/**
|
||||
* 所有节点定义的注册表
|
||||
*/
|
||||
export const NODE_DEFINITIONS: WorkflowNodeDefinition[] = [
|
||||
StartEventNode,
|
||||
EndEventNode,
|
||||
DeployNode,
|
||||
JenkinsBuildNode,
|
||||
// 在这里添加更多节点定义
|
||||
];
|
||||
|
||||
/**
|
||||
* 导出节点定义
|
||||
*/
|
||||
export {
|
||||
StartEventNode,
|
||||
EndEventNode,
|
||||
DeployNode,
|
||||
JenkinsBuildNode
|
||||
};
|
||||
@ -1,17 +0,0 @@
|
||||
/**
|
||||
* 节点服务 - 提供节点定义的访问接口
|
||||
*/
|
||||
|
||||
import {NODE_DEFINITIONS} from './definitions';
|
||||
import {WorkflowNodeDefinition} from './types';
|
||||
|
||||
/**
|
||||
* 获取所有节点定义列表
|
||||
*/
|
||||
export const getNodeDefinitionList = async (): Promise<WorkflowNodeDefinition[]> => {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(NODE_DEFINITIONS);
|
||||
}, 10);
|
||||
});
|
||||
};
|
||||
@ -1,5 +1,4 @@
|
||||
|
||||
// 节点分类 (从 ../types.ts 合并)
|
||||
// 节点分类
|
||||
export enum NodeCategory {
|
||||
EVENT = 'EVENT',
|
||||
TASK = 'TASK',
|
||||
@ -7,100 +6,7 @@ export enum NodeCategory {
|
||||
CONTAINER = 'CONTAINER'
|
||||
}
|
||||
|
||||
// 条件类型
|
||||
export type ConditionType = 'EXPRESSION' | 'SCRIPT' | 'DEFAULT';
|
||||
|
||||
// 边的条件配置
|
||||
export interface EdgeCondition {
|
||||
type: ConditionType;
|
||||
expression?: string;
|
||||
script?: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// JSON Schema 定义
|
||||
export interface JSONSchema {
|
||||
type: string;
|
||||
properties?: Record<string, any>;
|
||||
required?: string[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// UI 变量配置
|
||||
export interface NodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PortStyle {
|
||||
r: number;
|
||||
fill: string;
|
||||
stroke: string;
|
||||
strokeWidth?: number; // 新增:端口描边宽度
|
||||
filter?: string; // 新增:端口滤镜效果
|
||||
':hover'?: { // 新增:端口悬浮状态
|
||||
r?: number;
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PortAttributes {
|
||||
circle: PortStyle;
|
||||
}
|
||||
|
||||
export type PortPosition = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
export interface PortConfig {
|
||||
attrs: PortAttributes;
|
||||
position: PortPosition;
|
||||
}
|
||||
|
||||
export interface PortGroups {
|
||||
in?: PortConfig;
|
||||
out?: PortConfig;
|
||||
}
|
||||
|
||||
export type NodeShape = 'rect' | 'circle' | 'ellipse' | 'polygon';
|
||||
|
||||
export interface NodeStyle {
|
||||
fill: string;
|
||||
icon: string;
|
||||
stroke: string;
|
||||
iconColor: string;
|
||||
strokeWidth: number;
|
||||
// 现代化样式属性
|
||||
iconSize?: number; // 新增:图标大小
|
||||
borderRadius?: string; // 新增:圆角
|
||||
boxShadow?: string; // 新增:阴影
|
||||
transition?: string; // 新增:过渡效果
|
||||
fontSize?: string; // 新增:字体大小
|
||||
fontWeight?: string; // 新增:字体粗细
|
||||
fontFamily?: string; // 新增:字体族
|
||||
':hover'?: { // 新增:悬浮状态
|
||||
fill?: string;
|
||||
stroke?: string;
|
||||
boxShadow?: string;
|
||||
transform?: string;
|
||||
};
|
||||
':active'?: { // 新增:激活状态
|
||||
transform?: string;
|
||||
boxShadow?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UIConfig {
|
||||
size: NodeSize;
|
||||
ports: {
|
||||
groups: PortGroups;
|
||||
};
|
||||
shape: NodeShape;
|
||||
style: NodeStyle;
|
||||
position?: { x: number; y: number };
|
||||
}
|
||||
|
||||
// 节点类型和分类(保持原有的枚举格式)
|
||||
// 节点类型(完整定义,包含所有可能的类型)
|
||||
export enum NodeType {
|
||||
START_EVENT = 'START_EVENT',
|
||||
END_EVENT = 'END_EVENT',
|
||||
@ -114,6 +20,58 @@ export enum NodeType {
|
||||
CALL_ACTIVITY = 'CALL_ACTIVITY'
|
||||
}
|
||||
|
||||
// 节点类型到分类的映射
|
||||
export const NODE_CATEGORY_MAP: Record<NodeType, NodeCategory> = {
|
||||
[NodeType.START_EVENT]: NodeCategory.EVENT,
|
||||
[NodeType.END_EVENT]: NodeCategory.EVENT,
|
||||
[NodeType.USER_TASK]: NodeCategory.TASK,
|
||||
[NodeType.SERVICE_TASK]: NodeCategory.TASK,
|
||||
[NodeType.SCRIPT_TASK]: NodeCategory.TASK,
|
||||
[NodeType.DEPLOY_NODE]: NodeCategory.TASK,
|
||||
[NodeType.JENKINS_BUILD]: NodeCategory.TASK,
|
||||
[NodeType.GATEWAY_NODE]: NodeCategory.GATEWAY,
|
||||
[NodeType.SUB_PROCESS]: NodeCategory.CONTAINER,
|
||||
[NodeType.CALL_ACTIVITY]: NodeCategory.CONTAINER,
|
||||
};
|
||||
|
||||
// 获取节点分类的工具函数
|
||||
export const getNodeCategory = (nodeType: NodeType | string): NodeCategory => {
|
||||
return NODE_CATEGORY_MAP[nodeType as NodeType] || NodeCategory.TASK;
|
||||
};
|
||||
|
||||
// JSON Schema 定义
|
||||
/**
|
||||
* JSON Schema 接口 - 直接使用 Formily 官方提供的 ISchema 类型
|
||||
* 从 @formily/react 导入(它会从 @formily/json-schema 重新导出)
|
||||
*/
|
||||
import type { ISchema } from '@formily/react';
|
||||
export type JSONSchema = ISchema;
|
||||
|
||||
// UI 配置
|
||||
export interface NodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface NodeStyle {
|
||||
fill: string;
|
||||
icon: string;
|
||||
stroke: string;
|
||||
iconColor: string;
|
||||
strokeWidth: number;
|
||||
iconSize?: number;
|
||||
borderRadius?: string;
|
||||
boxShadow?: string;
|
||||
transition?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export interface UIConfig {
|
||||
size: NodeSize;
|
||||
style: NodeStyle;
|
||||
}
|
||||
|
||||
// 基础节点定义(只有基本配置)
|
||||
export interface BaseNodeDefinition {
|
||||
@ -150,5 +108,10 @@ export interface NodeInstanceData {
|
||||
|
||||
// UI位置信息
|
||||
position: { x: number; y: number };
|
||||
uiConfig: UIConfig; // 包含运行时可能更新的UI配置,如位置
|
||||
uiConfig: UIConfig;
|
||||
}
|
||||
|
||||
// 判断是否为可配置节点
|
||||
export const isConfigurableNode = (def: WorkflowNodeDefinition): def is ConfigurableNodeDefinition => {
|
||||
return 'inputMappingSchema' in def || 'outputMappingSchema' in def;
|
||||
};
|
||||
|
||||
@ -1,358 +0,0 @@
|
||||
import { Graph, Cell, Edge } from '@antv/x6';
|
||||
import { message, Modal } from 'antd';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Dropdown } from 'antd';
|
||||
import React from 'react';
|
||||
import type { NodeDefinitionResponse } from "../../nodes/nodeService";
|
||||
|
||||
/**
|
||||
* 节点样式管理
|
||||
*/
|
||||
export class NodeStyleManager {
|
||||
private static hoverStyle = {
|
||||
strokeWidth: 2,
|
||||
stroke: '#52c41a' // 绿色
|
||||
};
|
||||
|
||||
// 保存节点的原始样式
|
||||
static saveNodeOriginalStyle(node: any) {
|
||||
const data = node.getData();
|
||||
if (!data?.originalStyle) {
|
||||
const originalStyle = {
|
||||
stroke: node.getAttrByPath('body/stroke') || '#5F95FF',
|
||||
strokeWidth: node.getAttrByPath('body/strokeWidth') || 1
|
||||
};
|
||||
node.setData({
|
||||
...data,
|
||||
originalStyle
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取节点的原始样式
|
||||
static getNodeOriginalStyle(node: any) {
|
||||
const data = node.getData();
|
||||
return data?.originalStyle || {
|
||||
stroke: '#5F95FF',
|
||||
strokeWidth: 2
|
||||
};
|
||||
}
|
||||
|
||||
// 恢复节点的原始样式
|
||||
static resetNodeStyle(node: any) {
|
||||
const originalStyle = this.getNodeOriginalStyle(node);
|
||||
node.setAttrByPath('body/stroke', originalStyle.stroke);
|
||||
node.setAttrByPath('body/strokeWidth', originalStyle.strokeWidth);
|
||||
}
|
||||
|
||||
// 应用悬停样式
|
||||
static applyHoverStyle(node: any) {
|
||||
this.saveNodeOriginalStyle(node);
|
||||
node.setAttrByPath('body/stroke', this.hoverStyle.stroke);
|
||||
node.setAttrByPath('body/strokeWidth', this.hoverStyle.strokeWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接桩管理
|
||||
*/
|
||||
export class PortManager {
|
||||
static showPorts(nodeId: string) {
|
||||
const ports = document.querySelectorAll(`[data-cell-id="${nodeId}"] .x6-port-body`);
|
||||
ports.forEach((port) => {
|
||||
port.setAttribute('style', 'visibility: visible; fill: #fff; stroke: #85ca6d;');
|
||||
});
|
||||
}
|
||||
|
||||
static hidePorts(nodeId: string) {
|
||||
const ports = document.querySelectorAll(`[data-cell-id="${nodeId}"] .x6-port-body`);
|
||||
ports.forEach((port) => {
|
||||
port.setAttribute('style', 'visibility: hidden');
|
||||
});
|
||||
}
|
||||
|
||||
static hideAllPorts() {
|
||||
const ports = document.querySelectorAll('.x6-port-body');
|
||||
ports.forEach((port) => {
|
||||
port.setAttribute('style', 'visibility: hidden');
|
||||
});
|
||||
}
|
||||
|
||||
static showAllPorts() {
|
||||
const ports = document.querySelectorAll('.x6-port-body');
|
||||
ports.forEach((port) => {
|
||||
const portGroup = port.getAttribute('port-group');
|
||||
if (portGroup !== 'top') {
|
||||
port.setAttribute('style', 'visibility: visible; fill: #fff; stroke: #85ca6d;');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单管理
|
||||
*/
|
||||
export class ContextMenuManager {
|
||||
static createNodeContextMenu(
|
||||
e: MouseEvent,
|
||||
cell: Cell,
|
||||
graph: Graph,
|
||||
nodeDefinitions: NodeDefinitionResponse[],
|
||||
onEdit: (cell: Cell, nodeDefinition?: NodeDefinitionResponse) => void
|
||||
) {
|
||||
e.preventDefault();
|
||||
graph.cleanSelection();
|
||||
graph.select(cell);
|
||||
|
||||
const dropdownContainer = document.createElement('div');
|
||||
dropdownContainer.style.position = 'absolute';
|
||||
dropdownContainer.style.left = `${e.clientX}px`;
|
||||
dropdownContainer.style.top = `${e.clientY}px`;
|
||||
document.body.appendChild(dropdownContainer);
|
||||
|
||||
const root = createRoot(dropdownContainer);
|
||||
let isOpen = true;
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen = false;
|
||||
root.render(
|
||||
React.createElement(Dropdown, {
|
||||
menu: { items },
|
||||
open: false,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
root.unmount();
|
||||
document.body.removeChild(dropdownContainer);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, React.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: '1',
|
||||
label: '编辑',
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
const nodeDefinition = nodeDefinitions.find(def => def.nodeType === cell.getProp('nodeType'));
|
||||
onEdit(cell, nodeDefinition);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: '删除',
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该节点吗?',
|
||||
onOk: () => {
|
||||
cell.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
root.render(
|
||||
React.createElement(Dropdown, {
|
||||
menu: { items },
|
||||
open: isOpen
|
||||
}, React.createElement('div'))
|
||||
);
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!dropdownContainer.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}
|
||||
|
||||
static createEdgeContextMenu(
|
||||
e: MouseEvent,
|
||||
edge: Edge,
|
||||
graph: Graph,
|
||||
onEditCondition: (edge: Edge) => void
|
||||
) {
|
||||
e.preventDefault();
|
||||
graph.cleanSelection();
|
||||
graph.select(edge);
|
||||
|
||||
const sourceNode = graph.getCellById(edge.getSourceCellId());
|
||||
const isFromGateway = sourceNode.getProp('nodeType') === 'GATEWAY_NODE';
|
||||
|
||||
const dropdownContainer = document.createElement('div');
|
||||
dropdownContainer.style.position = 'absolute';
|
||||
dropdownContainer.style.left = `${e.clientX}px`;
|
||||
dropdownContainer.style.top = `${e.clientY}px`;
|
||||
document.body.appendChild(dropdownContainer);
|
||||
|
||||
const root = createRoot(dropdownContainer);
|
||||
let isOpen = true;
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen = false;
|
||||
root.render(
|
||||
React.createElement(Dropdown, {
|
||||
menu: { items },
|
||||
open: false,
|
||||
onOpenChange: (open: boolean) => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
root.unmount();
|
||||
document.body.removeChild(dropdownContainer);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, React.createElement('div'))
|
||||
);
|
||||
};
|
||||
|
||||
const items = [
|
||||
// 只有网关节点的出线才显示编辑条件选项
|
||||
...(isFromGateway ? [{
|
||||
key: 'edit',
|
||||
label: '编辑条件',
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
onEditCondition(edge);
|
||||
}
|
||||
}] : []),
|
||||
{
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
danger: true,
|
||||
onClick: () => {
|
||||
closeMenu();
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除该连接线吗?',
|
||||
onOk: () => {
|
||||
edge.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
root.render(
|
||||
React.createElement(Dropdown, {
|
||||
menu: { items },
|
||||
open: isOpen
|
||||
}, React.createElement('div'))
|
||||
);
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (!dropdownContainer.contains(e.target as Node)) {
|
||||
closeMenu();
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 边样式管理
|
||||
*/
|
||||
export class EdgeStyleManager {
|
||||
static readonly defaultEdgeAttrs = {
|
||||
line: {
|
||||
stroke: '#5F95FF',
|
||||
strokeWidth: 2,
|
||||
targetMarker: {
|
||||
name: 'classic',
|
||||
size: 7,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
static readonly hoverEdgeAttrs = {
|
||||
line: {
|
||||
stroke: '#52c41a',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
|
||||
static applyDefaultStyle(edge: Edge) {
|
||||
edge.setAttrs(this.defaultEdgeAttrs);
|
||||
}
|
||||
|
||||
static applyHoverStyle(edge: Edge) {
|
||||
edge.setAttrs(this.hoverEdgeAttrs);
|
||||
}
|
||||
|
||||
static addEdgeTools(edge: Edge) {
|
||||
edge.addTools([
|
||||
{
|
||||
name: 'vertices', // 顶点工具
|
||||
args: {
|
||||
padding: 4,
|
||||
attrs: {
|
||||
fill: '#fff',
|
||||
stroke: '#5F95FF',
|
||||
strokeWidth: 2,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'segments', // 线段工具
|
||||
args: {
|
||||
attrs: {
|
||||
fill: '#fff',
|
||||
stroke: '#5F95FF',
|
||||
strokeWidth: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择状态管理
|
||||
*/
|
||||
export class SelectionManager {
|
||||
static applySelectionStyle(cell: Cell) {
|
||||
if (cell.isNode()) {
|
||||
cell.setAttrByPath('body/stroke', '#1890ff');
|
||||
cell.setAttrByPath('body/strokeWidth', 2);
|
||||
cell.setAttrByPath('body/strokeDasharray', '5 5');
|
||||
} else if (cell.isEdge()) {
|
||||
cell.setAttrByPath('line/stroke', '#1890ff');
|
||||
cell.setAttrByPath('line/strokeWidth', 2);
|
||||
cell.setAttrByPath('line/strokeDasharray', '5 5');
|
||||
}
|
||||
}
|
||||
|
||||
static removeSelectionStyle(cell: Cell) {
|
||||
if (cell.isNode()) {
|
||||
cell.setAttrByPath('body/stroke', '#5F95FF');
|
||||
cell.setAttrByPath('body/strokeWidth', 2);
|
||||
cell.setAttrByPath('body/strokeDasharray', null);
|
||||
} else if (cell.isEdge()) {
|
||||
cell.setAttrByPath('line/stroke', '#5F95FF');
|
||||
cell.setAttrByPath('line/strokeWidth', 2);
|
||||
cell.setAttrByPath('line/strokeDasharray', null);
|
||||
}
|
||||
}
|
||||
|
||||
static selectAll(graph: Graph) {
|
||||
const cells = graph.getCells();
|
||||
if (cells.length === 0) {
|
||||
message.info('当前没有可选择的元素');
|
||||
return;
|
||||
}
|
||||
graph.resetSelection();
|
||||
graph.select(cells);
|
||||
// 为选中的元素添加高亮样式
|
||||
cells.forEach(cell => {
|
||||
this.applySelectionStyle(cell);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,216 +0,0 @@
|
||||
import { Graph, Edge } from '@antv/x6';
|
||||
import type { WorkflowNodeDefinition } from "../../nodes/types";
|
||||
import { NodeStyleManager, PortManager, ContextMenuManager, EdgeStyleManager, SelectionManager } from './eventHandlers';
|
||||
|
||||
/**
|
||||
* 事件注册器 - 负责注册所有图形事件
|
||||
*/
|
||||
export class EventRegistrar {
|
||||
private graph: Graph;
|
||||
private nodeDefinitions: WorkflowNodeDefinition[];
|
||||
private onNodeEdit: (cell: any, nodeDefinition?: WorkflowNodeDefinition) => void;
|
||||
private onEdgeEdit: (edge: Edge) => void;
|
||||
|
||||
constructor(
|
||||
graph: Graph,
|
||||
nodeDefinitions: WorkflowNodeDefinition[],
|
||||
onNodeEdit: (cell: any, nodeDefinition?: WorkflowNodeDefinition) => void,
|
||||
onEdgeEdit: (edge: Edge) => void
|
||||
) {
|
||||
this.graph = graph;
|
||||
this.nodeDefinitions = nodeDefinitions;
|
||||
this.onNodeEdit = onNodeEdit;
|
||||
this.onEdgeEdit = onEdgeEdit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册所有事件
|
||||
*/
|
||||
registerAllEvents() {
|
||||
this.registerNodeEvents();
|
||||
this.registerEdgeEvents();
|
||||
this.registerSelectionEvents();
|
||||
this.registerCanvasEvents();
|
||||
this.registerHistoryEvents();
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册节点相关事件
|
||||
*/
|
||||
private registerNodeEvents() {
|
||||
// 节点悬停事件
|
||||
this.graph.on('node:mouseenter', ({node}) => {
|
||||
NodeStyleManager.applyHoverStyle(node);
|
||||
PortManager.showPorts(node.id);
|
||||
});
|
||||
|
||||
this.graph.on('node:mouseleave', ({node}) => {
|
||||
NodeStyleManager.resetNodeStyle(node);
|
||||
PortManager.hidePorts(node.id);
|
||||
});
|
||||
|
||||
// 节点拖动事件
|
||||
this.graph.on('node:drag:start', ({node}) => {
|
||||
NodeStyleManager.saveNodeOriginalStyle(node);
|
||||
});
|
||||
|
||||
this.graph.on('node:moved', ({node}) => {
|
||||
NodeStyleManager.resetNodeStyle(node);
|
||||
PortManager.hidePorts(node.id);
|
||||
});
|
||||
|
||||
// 节点点击事件
|
||||
this.graph.on('node:click', ({node}) => {
|
||||
const selectedNode = this.graph.getSelectedCells()[0];
|
||||
if (selectedNode && selectedNode.isNode() && selectedNode.id !== node.id) {
|
||||
NodeStyleManager.resetNodeStyle(selectedNode);
|
||||
}
|
||||
this.graph.resetSelection();
|
||||
this.graph.select(node);
|
||||
});
|
||||
|
||||
// 节点双击事件
|
||||
this.graph.on('node:dblclick', ({node}) => {
|
||||
const nodeType = node.getProp('nodeType');
|
||||
const nodeDefinition = this.nodeDefinitions.find(def => def.nodeType === nodeType);
|
||||
if (nodeDefinition) {
|
||||
this.onNodeEdit(node, nodeDefinition);
|
||||
}
|
||||
});
|
||||
|
||||
// 节点右键菜单
|
||||
this.graph.on('node:contextmenu', ({cell, e}) => {
|
||||
ContextMenuManager.createNodeContextMenu(
|
||||
e as MouseEvent,
|
||||
cell,
|
||||
this.graph,
|
||||
this.nodeDefinitions,
|
||||
this.onNodeEdit
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册边相关事件
|
||||
*/
|
||||
private registerEdgeEvents() {
|
||||
// 边连接事件
|
||||
this.graph.on('edge:connected', ({edge}) => {
|
||||
EdgeStyleManager.applyDefaultStyle(edge);
|
||||
});
|
||||
|
||||
// 边悬停事件
|
||||
this.graph.on('edge:mouseenter', ({edge}) => {
|
||||
EdgeStyleManager.applyHoverStyle(edge);
|
||||
PortManager.showAllPorts();
|
||||
});
|
||||
|
||||
this.graph.on('edge:mouseleave', ({edge}) => {
|
||||
EdgeStyleManager.applyDefaultStyle(edge);
|
||||
PortManager.hideAllPorts();
|
||||
});
|
||||
|
||||
// 边选择事件
|
||||
this.graph.on('edge:selected', ({edge}) => {
|
||||
EdgeStyleManager.addEdgeTools(edge);
|
||||
});
|
||||
|
||||
this.graph.on('edge:unselected', ({edge}) => {
|
||||
edge.removeTools();
|
||||
});
|
||||
|
||||
// 边移动事件
|
||||
this.graph.on('edge:moved', ({edge, terminal}) => {
|
||||
if (!edge || !terminal) return;
|
||||
|
||||
const isSource = terminal.type === 'source';
|
||||
const source = isSource ? terminal : edge.getSource();
|
||||
const target = isSource ? edge.getTarget() : terminal;
|
||||
|
||||
if (source && target) {
|
||||
edge.remove();
|
||||
this.graph.addEdge({
|
||||
source: {
|
||||
cell: source.cell,
|
||||
port: source.port,
|
||||
},
|
||||
target: {
|
||||
cell: target.cell,
|
||||
port: target.port,
|
||||
},
|
||||
attrs: EdgeStyleManager.defaultEdgeAttrs,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 边改变事件
|
||||
this.graph.on('edge:change:source edge:change:target', ({edge}) => {
|
||||
if (edge) {
|
||||
EdgeStyleManager.applyDefaultStyle(edge);
|
||||
}
|
||||
});
|
||||
|
||||
// 边右键菜单
|
||||
this.graph.on('edge:contextmenu', ({cell, e}) => {
|
||||
ContextMenuManager.createEdgeContextMenu(
|
||||
e as MouseEvent,
|
||||
cell as Edge,
|
||||
this.graph,
|
||||
this.onEdgeEdit
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册选择相关事件
|
||||
*/
|
||||
private registerSelectionEvents() {
|
||||
this.graph.on('selection:changed', ({selected, removed}) => {
|
||||
// 处理新选中的元素
|
||||
selected.forEach(cell => {
|
||||
SelectionManager.applySelectionStyle(cell);
|
||||
});
|
||||
|
||||
// 处理取消选中的元素
|
||||
removed.forEach(cell => {
|
||||
SelectionManager.removeSelectionStyle(cell);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册画布事件
|
||||
*/
|
||||
private registerCanvasEvents() {
|
||||
// 点击空白处事件
|
||||
this.graph.on('blank:click', () => {
|
||||
const selectedNode = this.graph.getSelectedCells()[0];
|
||||
if (selectedNode && selectedNode.isNode()) {
|
||||
NodeStyleManager.resetNodeStyle(selectedNode);
|
||||
}
|
||||
this.graph.resetSelection();
|
||||
});
|
||||
|
||||
// 禁用默认右键菜单
|
||||
this.graph.on('blank:contextmenu', ({e}) => {
|
||||
e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册历史记录事件
|
||||
*/
|
||||
private registerHistoryEvents() {
|
||||
this.graph.on('cell:added', () => {
|
||||
// 可以在这里添加额外的逻辑
|
||||
});
|
||||
|
||||
this.graph.on('cell:removed', () => {
|
||||
// 可以在这里添加额外的逻辑
|
||||
});
|
||||
|
||||
this.graph.on('cell:changed', () => {
|
||||
// 可以在这里添加额外的逻辑
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,164 +0,0 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
|
||||
/**
|
||||
* X6 图形基础配置
|
||||
*/
|
||||
export const createGraphConfig = (container: HTMLElement): Graph.Options => ({
|
||||
container,
|
||||
grid: {
|
||||
size: 10,
|
||||
visible: true,
|
||||
type: 'dot',
|
||||
args: {
|
||||
color: '#a0a0a0',
|
||||
thickness: 1,
|
||||
},
|
||||
},
|
||||
connecting: {
|
||||
snap: true, // 连线时自动吸附
|
||||
allowBlank: false, // 禁止连线到空白位置
|
||||
allowLoop: false, // 禁止自环
|
||||
allowNode: false, // 禁止连接到节点(只允许连接到连接桩)
|
||||
allowEdge: false, // 禁止边连接到边
|
||||
connector: {
|
||||
name: 'rounded',
|
||||
args: {
|
||||
radius: 8
|
||||
}
|
||||
},
|
||||
router: {
|
||||
name: 'manhattan',
|
||||
args: {
|
||||
padding: 1
|
||||
}
|
||||
},
|
||||
validateMagnet({magnet}) {
|
||||
return magnet.getAttribute('port-group') !== 'top';
|
||||
},
|
||||
validateEdge() {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
highlighting: {
|
||||
magnetAvailable: {
|
||||
name: 'stroke',
|
||||
args: {
|
||||
padding: 4,
|
||||
attrs: {
|
||||
strokeWidth: 4,
|
||||
stroke: '#52c41a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// clipboard: {
|
||||
// enabled: true,
|
||||
// },
|
||||
selecting: {
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
rubberband: true,
|
||||
movable: true,
|
||||
showNodeSelectionBox: false, // 禁用节点选择框
|
||||
showEdgeSelectionBox: false, // 禁用边选择框
|
||||
selectNodeOnMoved: false,
|
||||
selectEdgeOnMoved: false,
|
||||
},
|
||||
snapline: true,
|
||||
keyboard: {
|
||||
enabled: true,
|
||||
global: false,
|
||||
},
|
||||
panning: {
|
||||
enabled: true,
|
||||
eventTypes: ['rightMouseDown'], // 右键按下时启用画布拖拽
|
||||
},
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
modifiers: ['ctrl', 'meta'],
|
||||
minScale: 0.2,
|
||||
maxScale: 2,
|
||||
},
|
||||
edgeMovable: true, // 允许边移动
|
||||
edgeLabelMovable: true, // 允许边标签移动
|
||||
vertexAddable: true, // 允许添加顶点
|
||||
vertexMovable: true, // 允许顶点移动
|
||||
vertexDeletable: true, // 允许删除顶点
|
||||
});
|
||||
|
||||
/**
|
||||
* 连接验证逻辑
|
||||
*/
|
||||
export const createValidateConnection = (graph: Graph) => {
|
||||
return ({sourceCell, targetCell, sourceMagnet, targetMagnet}: any) => {
|
||||
if (sourceCell === targetCell) {
|
||||
return false; // 禁止自连接
|
||||
}
|
||||
if (!sourceMagnet || !targetMagnet) {
|
||||
return false; // 需要有效的连接点
|
||||
}
|
||||
|
||||
// 获取源节点和目标节点的类型
|
||||
const sourceNodeType = sourceCell.getProp('nodeType');
|
||||
const targetNodeType = targetCell.getProp('nodeType');
|
||||
|
||||
// 如果源节点或目标节点是网关类型,允许多条连线
|
||||
if (sourceNodeType === 'GATEWAY_NODE' || targetNodeType === 'GATEWAY_NODE') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 对于其他类型的节点,检查是否已存在连接
|
||||
const edges = graph.getEdges();
|
||||
const exists = edges.some(edge => {
|
||||
const source = edge.getSource();
|
||||
const target = edge.getTarget();
|
||||
return (
|
||||
(source as any).cell === sourceCell.id &&
|
||||
(target as any).cell === targetCell.id &&
|
||||
(source as any).port === sourceMagnet.getAttribute('port') &&
|
||||
(target as any).port === targetMagnet.getAttribute('port')
|
||||
);
|
||||
});
|
||||
return !exists;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 小地图配置
|
||||
*/
|
||||
export const createMiniMapConfig = (
|
||||
container: HTMLElement,
|
||||
width: number,
|
||||
height: number,
|
||||
scale: number
|
||||
) => ({
|
||||
container,
|
||||
width,
|
||||
height,
|
||||
padding: 5,
|
||||
scalable: false,
|
||||
minScale: scale * 0.8,
|
||||
maxScale: scale * 1.2,
|
||||
graphOptions: {
|
||||
connecting: {
|
||||
connector: 'rounded',
|
||||
connectionPoint: 'anchor',
|
||||
router: {
|
||||
name: 'manhattan',
|
||||
},
|
||||
},
|
||||
async: true,
|
||||
frozen: true,
|
||||
interacting: false,
|
||||
grid: false
|
||||
},
|
||||
viewport: {
|
||||
padding: 0,
|
||||
fitToContent: false,
|
||||
initialPosition: {
|
||||
x: 0,
|
||||
y: 0
|
||||
},
|
||||
initialScale: scale
|
||||
}
|
||||
});
|
||||
@ -1,166 +0,0 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import { Selection } from '@antv/x6-plugin-selection';
|
||||
import { MiniMap } from '@antv/x6-plugin-minimap';
|
||||
import { Clipboard } from '@antv/x6-plugin-clipboard';
|
||||
import { History } from '@antv/x6-plugin-history';
|
||||
import { Transform } from '@antv/x6-plugin-transform';
|
||||
import { createGraphConfig, createValidateConnection, createMiniMapConfig } from './graphConfig';
|
||||
|
||||
/**
|
||||
* X6 图形初始化器
|
||||
*/
|
||||
export class GraphInitializer {
|
||||
private graphContainer: HTMLElement;
|
||||
private minimapContainer: HTMLElement;
|
||||
|
||||
constructor(graphContainer: HTMLElement, minimapContainer: HTMLElement) {
|
||||
this.graphContainer = graphContainer;
|
||||
this.minimapContainer = minimapContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化图形实例
|
||||
*/
|
||||
initializeGraph(): Graph {
|
||||
// 获取主画布容器尺寸
|
||||
const containerWidth = this.graphContainer.clientWidth;
|
||||
const containerHeight = this.graphContainer.clientHeight;
|
||||
|
||||
// 计算小地图尺寸
|
||||
const MINIMAP_BASE_WIDTH = 200;
|
||||
const minimapWidth = MINIMAP_BASE_WIDTH;
|
||||
const minimapHeight = Math.round((MINIMAP_BASE_WIDTH * containerHeight) / containerWidth);
|
||||
const scale = minimapWidth / containerWidth;
|
||||
|
||||
// 设置CSS变量
|
||||
this.setCSSVariables(minimapWidth, minimapHeight);
|
||||
|
||||
// 创建图形配置
|
||||
const config = createGraphConfig(this.graphContainer);
|
||||
const graph = new Graph(config);
|
||||
|
||||
// 设置连接验证
|
||||
(graph as any).options.connecting.validateConnection = createValidateConnection(graph);
|
||||
|
||||
// 注册插件
|
||||
this.registerPlugins(graph, minimapWidth, minimapHeight, scale);
|
||||
|
||||
// 设置键盘事件
|
||||
this.setupKeyboardEvents(graph);
|
||||
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置CSS变量
|
||||
*/
|
||||
private setCSSVariables(minimapWidth: number, minimapHeight: number) {
|
||||
document.documentElement.style.setProperty('--minimap-width', `${minimapWidth}px`);
|
||||
document.documentElement.style.setProperty('--minimap-height', `${minimapHeight}px`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册插件
|
||||
*/
|
||||
private registerPlugins(graph: Graph, minimapWidth: number, minimapHeight: number, scale: number) {
|
||||
// History 插件
|
||||
const history = new History({
|
||||
enabled: true,
|
||||
beforeAddCommand(event: any, args: any) {
|
||||
return true;
|
||||
},
|
||||
afterExecuteCommand: () => {},
|
||||
afterUndo: () => {},
|
||||
afterRedo: () => {},
|
||||
});
|
||||
|
||||
// Selection 插件
|
||||
const selection = new Selection({
|
||||
enabled: true,
|
||||
multiple: true,
|
||||
rubberband: true,
|
||||
movable: true,
|
||||
showNodeSelectionBox: false,
|
||||
showEdgeSelectionBox: false,
|
||||
selectNodeOnMoved: false,
|
||||
selectEdgeOnMoved: false,
|
||||
multipleSelectionModifiers: ['ctrl', 'meta'],
|
||||
pointerEvents: 'auto'
|
||||
});
|
||||
|
||||
// MiniMap 插件
|
||||
const minimapConfig = createMiniMapConfig(this.minimapContainer, minimapWidth, minimapHeight, scale);
|
||||
const minimap = new MiniMap(minimapConfig);
|
||||
|
||||
// Transform 插件
|
||||
const transform = new Transform({
|
||||
resizing: false,
|
||||
rotating: false,
|
||||
});
|
||||
|
||||
// 注册插件
|
||||
graph.use(selection);
|
||||
graph.use(minimap);
|
||||
graph.use(new Clipboard());
|
||||
graph.use(history);
|
||||
graph.use(transform);
|
||||
|
||||
// 扩展 graph 对象,添加 history 属性
|
||||
(graph as any).history = history;
|
||||
|
||||
// 设置变换事件监听
|
||||
this.setupTransformListener(graph, minimap, scale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置变换监听
|
||||
*/
|
||||
private setupTransformListener(graph: Graph, minimap: MiniMap, scale: number) {
|
||||
graph.on('transform', () => {
|
||||
if (minimap) {
|
||||
const mainViewport = graph.getView().getVisibleArea();
|
||||
(minimap as any).viewport.scale = scale;
|
||||
(minimap as any).viewport.center(mainViewport.center);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置键盘事件
|
||||
*/
|
||||
private setupKeyboardEvents(graph: Graph) {
|
||||
this.graphContainer?.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
// 只有当画布或其子元素被聚焦时才处理快捷键
|
||||
if (!this.graphContainer?.contains(document.activeElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+A 或 Command+A (Mac)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
||||
e.preventDefault(); // 阻止浏览器默认的全选行为
|
||||
if (!graph) return;
|
||||
|
||||
const cells = graph.getCells();
|
||||
if (cells.length > 0) {
|
||||
graph.resetSelection();
|
||||
graph.select(cells);
|
||||
// 为选中的元素添加高亮样式
|
||||
cells.forEach(cell => {
|
||||
if (cell.isNode()) {
|
||||
cell.setAttrByPath('body/stroke', '#1890ff');
|
||||
cell.setAttrByPath('body/strokeWidth', 3);
|
||||
cell.setAttrByPath('body/strokeDasharray', '5 5');
|
||||
} else if (cell.isEdge()) {
|
||||
cell.setAttrByPath('line/stroke', '#1890ff');
|
||||
cell.setAttrByPath('line/strokeWidth', 3);
|
||||
cell.setAttrByPath('line/strokeDasharray', '5 5');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 确保画布可以接收键盘事件
|
||||
this.graphContainer?.setAttribute('tabindex', '0');
|
||||
}
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
import {Graph} from '@antv/x6';
|
||||
import {WorkflowNodeDefinition, NodeInstanceData} from '../nodes/types';
|
||||
|
||||
/**
|
||||
* 从节点定义创建新节点
|
||||
*/
|
||||
export const createNodeFromDefinition = (
|
||||
graph: Graph,
|
||||
nodeDefinition: WorkflowNodeDefinition,
|
||||
position: { x: number; y: number }
|
||||
) => {
|
||||
const {uiConfig} = nodeDefinition;
|
||||
|
||||
// 创建节点配置 - 直接使用X6原生形状名称
|
||||
const nodeConfig = {
|
||||
shape: uiConfig.shape,
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
width: uiConfig.size.width,
|
||||
height: uiConfig.size.height,
|
||||
attrs: {
|
||||
body: {
|
||||
...uiConfig.style,
|
||||
...(uiConfig.shape === 'polygon' ? {
|
||||
refPoints: '0,10 10,0 20,10 10,20',
|
||||
} : {})
|
||||
},
|
||||
label: {
|
||||
text: nodeDefinition.nodeName,
|
||||
fontSize: 12,
|
||||
fill: '#000'
|
||||
},
|
||||
},
|
||||
ports: convertPortConfig(uiConfig.ports),
|
||||
// 同时设置为props和data,方便访问
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
data: {
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
// 新创建的节点,configs等数据为空
|
||||
configs: {},
|
||||
inputMapping: {},
|
||||
outputMapping: {}
|
||||
}
|
||||
};
|
||||
|
||||
return graph.addNode(nodeConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* 从保存的数据恢复节点
|
||||
*/
|
||||
export const restoreNodeFromData = (
|
||||
graph: Graph,
|
||||
nodeData: NodeInstanceData,
|
||||
nodeDefinition: WorkflowNodeDefinition
|
||||
) => {
|
||||
// 从节点定义中获取UI配置,兼容旧数据中的uiConfig
|
||||
const uiConfig = nodeData.uiConfig || nodeDefinition.uiConfig;
|
||||
|
||||
// 创建节点配置 - 直接使用X6原生形状名称
|
||||
const nodeConfig = {
|
||||
id: nodeData.nodeCode, // 使用保存的ID
|
||||
shape: uiConfig.shape,
|
||||
x: nodeData.position.x,
|
||||
y: nodeData.position.y,
|
||||
width: uiConfig.size.width,
|
||||
height: uiConfig.size.height,
|
||||
attrs: {
|
||||
body: {
|
||||
...uiConfig.style,
|
||||
...(uiConfig.shape === 'polygon' ? {
|
||||
refPoints: '0,10 10,0 20,10 10,20',
|
||||
} : {})
|
||||
},
|
||||
label: {
|
||||
text: nodeData.nodeName,
|
||||
fontSize: 12,
|
||||
fill: '#000'
|
||||
},
|
||||
},
|
||||
ports: convertPortConfig(uiConfig.ports),
|
||||
// 同时设置为props和data,方便访问
|
||||
nodeType: nodeData.nodeType,
|
||||
nodeCode: nodeData.nodeCode,
|
||||
data: {
|
||||
nodeType: nodeData.nodeType,
|
||||
nodeCode: nodeData.nodeCode,
|
||||
nodeName: nodeData.nodeName,
|
||||
// 统一使用configs字段名
|
||||
configs: nodeData.configs || {},
|
||||
inputMapping: nodeData.inputMapping || {},
|
||||
outputMapping: nodeData.outputMapping || {}
|
||||
}
|
||||
};
|
||||
|
||||
return graph.addNode(nodeConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换端口配置为X6格式
|
||||
*/
|
||||
const convertPortConfig = (ports: any) => {
|
||||
if (!ports?.groups) return {items: []};
|
||||
|
||||
const groups: any = {};
|
||||
const items: any[] = [];
|
||||
|
||||
Object.entries(ports.groups).forEach(([key, group]: [string, any]) => {
|
||||
groups[key] = {
|
||||
position: group.position,
|
||||
attrs: {
|
||||
circle: {
|
||||
...group.attrs.circle,
|
||||
magnet: true,
|
||||
}
|
||||
}
|
||||
};
|
||||
items.push({group: key});
|
||||
});
|
||||
|
||||
return {groups, items};
|
||||
};
|
||||
@ -1,72 +1,33 @@
|
||||
import { Graph, Cell } from '@antv/x6';
|
||||
import type { FlowNode, FlowEdge } from '../types';
|
||||
import { NodeType } from '../types';
|
||||
|
||||
interface ValidationResult {
|
||||
/**
|
||||
* 验证结果接口
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验节点配置
|
||||
* @param node 节点
|
||||
* @param nodeDefinition 节点定义
|
||||
* 验证工作流是否为空
|
||||
*/
|
||||
const validateNodeConfig = (node: Cell, nodeDefinition: any): ValidationResult => {
|
||||
const panelVariables = node.getProp('panelVariables');
|
||||
const localVariables = node.getProp('localVariables');
|
||||
|
||||
// 校验面板变量
|
||||
if (nodeDefinition?.panelVariablesSchema?.required) {
|
||||
for (const field of nodeDefinition.panelVariablesSchema.required) {
|
||||
if (!panelVariables?.[field]) {
|
||||
const fieldTitle = nodeDefinition.panelVariablesSchema.properties[field]?.title || field;
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点 "${node.attr('label/text')}" 的面板变量 "${fieldTitle}" 是必填项`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 校验环境变量
|
||||
if (nodeDefinition?.localVariablesSchema?.required) {
|
||||
for (const field of nodeDefinition.localVariablesSchema.required) {
|
||||
if (!localVariables?.[field]) {
|
||||
const fieldTitle = nodeDefinition.localVariablesSchema.properties[field]?.title || field;
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点 "${node.attr('label/text')}" 的环境变量 "${fieldTitle}" 是必填项`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验流程图是否为空
|
||||
* @param graph 流程图实例
|
||||
*/
|
||||
const validateGraphNotEmpty = (graph: Graph): ValidationResult => {
|
||||
const nodes = graph.getNodes();
|
||||
const validateNotEmpty = (nodes: FlowNode[]): ValidationResult => {
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '流程图中没有任何节点'
|
||||
message: '流程图中没有任何节点,请至少添加一个节点'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验必要节点
|
||||
* @param graph 流程图实例
|
||||
* 验证必需的开始和结束节点
|
||||
*/
|
||||
const validateRequiredNodes = (graph: Graph): ValidationResult => {
|
||||
const nodes = graph.getNodes();
|
||||
|
||||
const validateRequiredNodes = (nodes: FlowNode[]): ValidationResult => {
|
||||
// 检查开始节点
|
||||
const hasStartNode = nodes.some(node => node.getProp('nodeType') === 'START_EVENT');
|
||||
const hasStartNode = nodes.some(node => node.data.nodeType === NodeType.START_EVENT);
|
||||
if (!hasStartNode) {
|
||||
return {
|
||||
valid: false,
|
||||
@ -75,7 +36,7 @@ const validateRequiredNodes = (graph: Graph): ValidationResult => {
|
||||
}
|
||||
|
||||
// 检查结束节点
|
||||
const hasEndNode = nodes.some(node => node.getProp('nodeType') === 'END_EVENT');
|
||||
const hasEndNode = nodes.some(node => node.data.nodeType === NodeType.END_EVENT);
|
||||
if (!hasEndNode) {
|
||||
return {
|
||||
valid: false,
|
||||
@ -87,35 +48,100 @@ const validateRequiredNodes = (graph: Graph): ValidationResult => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验节点连接
|
||||
* @param graph 流程图实例
|
||||
* 验证节点连接完整性
|
||||
* 检查是否有孤立的节点(除了开始和结束节点可能只有单向连接)
|
||||
*/
|
||||
const validateNodeConnections = (graph: Graph): ValidationResult => {
|
||||
const nodes = graph.getNodes();
|
||||
const edges = graph.getEdges();
|
||||
const validateNodeConnections = (nodes: FlowNode[], edges: FlowEdge[]): ValidationResult => {
|
||||
if (nodes.length <= 1) {
|
||||
return { valid: true }; // 单节点或空流程不需要验证连接
|
||||
}
|
||||
|
||||
if (edges.length < nodes.length - 1) {
|
||||
// 构建节点连接映射
|
||||
const nodeConnections = new Map<string, { incoming: number; outgoing: number }>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
nodeConnections.set(node.id, { incoming: 0, outgoing: 0 });
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
const source = nodeConnections.get(edge.source);
|
||||
const target = nodeConnections.get(edge.target);
|
||||
|
||||
if (source) source.outgoing++;
|
||||
if (target) target.incoming++;
|
||||
});
|
||||
|
||||
// 检查每个节点的连接情况
|
||||
for (const [nodeId, connections] of nodeConnections.entries()) {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const nodeType = node.data.nodeType;
|
||||
|
||||
// 开始节点必须有出边
|
||||
if (nodeType === NodeType.START_EVENT && connections.outgoing === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '存在未连接的节点,请确保所有节点都已正确连接'
|
||||
message: `开始节点"${node.data.label}"没有连接到任何其他节点`
|
||||
};
|
||||
}
|
||||
|
||||
// 结束节点必须有入边
|
||||
if (nodeType === NodeType.END_EVENT && connections.incoming === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `结束节点"${node.data.label}"没有被任何节点连接`
|
||||
};
|
||||
}
|
||||
|
||||
// 中间节点必须既有入边又有出边
|
||||
if (nodeType !== NodeType.START_EVENT && nodeType !== NodeType.END_EVENT) {
|
||||
if (connections.incoming === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"没有输入连接,请确保它被其他节点连接`
|
||||
};
|
||||
}
|
||||
if (connections.outgoing === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"没有输出连接,请确保它连接到其他节点`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验所有节点配置
|
||||
* @param graph 流程图实例
|
||||
* 验证节点配置完整性
|
||||
* 检查节点是否有必填配置
|
||||
*/
|
||||
const validateAllNodesConfig = (graph: Graph): ValidationResult => {
|
||||
const nodes = graph.getNodes();
|
||||
|
||||
const validateNodeConfigs = (nodes: FlowNode[]): ValidationResult => {
|
||||
for (const node of nodes) {
|
||||
const nodeDefinition = node.getProp('nodeDefinition');
|
||||
const result = validateNodeConfig(node, nodeDefinition);
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
// 检查节点是否有配置数据
|
||||
if (!node.data.configs) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"缺少配置信息,请配置该节点`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查节点名称
|
||||
if (!node.data.configs.nodeName || node.data.configs.nodeName.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"的名称不能为空`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查节点编码
|
||||
if (!node.data.configs.nodeCode || node.data.configs.nodeCode.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"的编码不能为空`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -123,20 +149,54 @@ const validateAllNodesConfig = (graph: Graph): ValidationResult => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 校验整个流程图
|
||||
* @param graph 流程图实例
|
||||
* 验证边条件配置
|
||||
* 如果边配置了条件,确保条件有效
|
||||
*/
|
||||
export const validateWorkflow = (graph: Graph): ValidationResult => {
|
||||
// 按顺序执行所有验证
|
||||
const validateEdgeConditions = (edges: FlowEdge[]): ValidationResult => {
|
||||
for (const edge of edges) {
|
||||
const condition = edge.data?.condition;
|
||||
|
||||
if (condition) {
|
||||
// 如果是表达式类型,检查表达式是否为空
|
||||
if (condition.type === 'EXPRESSION') {
|
||||
if (!condition.expression || condition.expression.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `边"${edge.id}"配置了表达式条件,但表达式为空`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查优先级是否有效
|
||||
if (condition.priority < 1 || condition.priority > 999) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `边"${edge.id}"的优先级必须在1-999之间`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的工作流验证
|
||||
* 依次执行所有验证规则,任何一个失败就返回失败
|
||||
*/
|
||||
export const validateWorkflow = (nodes: FlowNode[], edges: FlowEdge[]): ValidationResult => {
|
||||
// 定义验证规则链
|
||||
const validators = [
|
||||
validateGraphNotEmpty,
|
||||
validateRequiredNodes,
|
||||
validateAllNodesConfig,
|
||||
validateNodeConnections
|
||||
() => validateNotEmpty(nodes),
|
||||
() => validateRequiredNodes(nodes),
|
||||
() => validateNodeConnections(nodes, edges),
|
||||
() => validateNodeConfigs(nodes),
|
||||
() => validateEdgeConditions(edges),
|
||||
];
|
||||
|
||||
// 依次执行验证
|
||||
for (const validator of validators) {
|
||||
const result = validator(graph);
|
||||
const result = validator();
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
@ -144,3 +204,4 @@ export const validateWorkflow = (graph: Graph): ValidationResult => {
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
|
||||
@ -1,164 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Modal, Form, Input, Select, message } from 'antd';
|
||||
import * as service from '../service';
|
||||
import type { WorkflowDefinition, WorkflowCategory } from '../types';
|
||||
|
||||
interface EditModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
record?: WorkflowDefinition;
|
||||
}
|
||||
|
||||
const EditModal: React.FC<EditModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onSuccess,
|
||||
record
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [categories, setCategories] = React.useState<WorkflowCategory[]>([]);
|
||||
|
||||
// 加载工作流分类
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await service.getWorkflowCategories();
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error('加载工作流分类失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (visible) {
|
||||
loadCategories();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
// 设置表单初始值
|
||||
useEffect(() => {
|
||||
if (visible && record) {
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
key: record.key,
|
||||
description: record.description,
|
||||
category: record.category,
|
||||
triggers: record.triggers || []
|
||||
});
|
||||
} else if (visible) {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [visible, record, form]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setLoading(true);
|
||||
|
||||
if (record) {
|
||||
await service.updateDefinition(record.id, values);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await service.createDefinition({
|
||||
...values,
|
||||
status: 'DRAFT',
|
||||
graph: { nodes: [], edges: [] },
|
||||
formConfig: { formItems: [] }
|
||||
});
|
||||
message.success('创建成功');
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={record ? '编辑工作流' : '新建工作流'}
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
onOk={handleSubmit}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
triggers: []
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="流程名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入流程名称' }]}
|
||||
>
|
||||
<Input placeholder="请输入流程名称" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="流程标识"
|
||||
name="key"
|
||||
rules={[
|
||||
{ required: true, message: '请输入流程标识' },
|
||||
{ pattern: /^[a-zA-Z][a-zA-Z0-9_]*$/, message: '流程标识只能包含字母、数字和下划线,且以字母开头' }
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入流程标识" disabled={!!record} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="流程分类"
|
||||
name="category"
|
||||
rules={[{ required: true, message: '请选择流程分类' }]}
|
||||
>
|
||||
<Select placeholder="请选择流程分类">
|
||||
{categories.map(category => (
|
||||
<Select.Option key={category.code} value={category.code}>
|
||||
{category.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="触发方式"
|
||||
name="triggers"
|
||||
rules={[{ required: true, message: '请选择至少一种触发方式' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="请选择触发方式"
|
||||
options={categories.find(c => c.code === form.getFieldValue('category'))?.supportedTriggers?.map(trigger => ({
|
||||
label: trigger.label,
|
||||
value: trigger.code
|
||||
})) || []}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="描述"
|
||||
name="description"
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder="请输入流程描述"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
@ -1,247 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Card, Button, Space, Tag, message, Modal } from 'antd';
|
||||
import { PlusOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import * as service from './service';
|
||||
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategory } from './types';
|
||||
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||
import EditModal from './components/EditModal';
|
||||
|
||||
const { confirm } = Modal;
|
||||
|
||||
const WorkflowDefinitionList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageData, setPageData] = useState<{
|
||||
content: WorkflowDefinition[];
|
||||
totalElements: number;
|
||||
size: number;
|
||||
number: number;
|
||||
} | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentRecord, setCurrentRecord] = useState<WorkflowDefinition>();
|
||||
const [categories, setCategories] = useState<WorkflowCategory[]>([]);
|
||||
const [query, setQuery] = useState<WorkflowDefinitionQuery>({
|
||||
pageNum: DEFAULT_CURRENT - 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE
|
||||
});
|
||||
|
||||
const loadData = async (params: WorkflowDefinitionQuery) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await service.getDefinitions(params);
|
||||
setPageData(data);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCategories = async () => {
|
||||
try {
|
||||
const data = await service.getWorkflowCategories();
|
||||
setCategories(data);
|
||||
} catch (error) {
|
||||
console.error('加载工作流分类失败:', error);
|
||||
message.error('加载工作流分类失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(query);
|
||||
loadCategories();
|
||||
}, [query]);
|
||||
|
||||
const handleCreateFlow = () => {
|
||||
setCurrentRecord(undefined);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEditFlow = (record: WorkflowDefinition) => {
|
||||
setCurrentRecord(record);
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleDesignFlow = (record: WorkflowDefinition) => {
|
||||
// 新的React Flow设计器路径
|
||||
navigate(`/workflow2/design/${record.id}`);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setModalVisible(false);
|
||||
setCurrentRecord(undefined);
|
||||
};
|
||||
|
||||
const handleDeploy = async (id: number) => {
|
||||
confirm({
|
||||
title: '确认发布',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定要发布该流程定义吗?发布后将不能修改。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await service.publishDefinition(id);
|
||||
message.success('发布成功');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
confirm({
|
||||
title: '确认删除',
|
||||
icon: <ExclamationCircleOutlined />,
|
||||
content: '确定要删除该流程定义吗?删除后不可恢复。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await service.deleteDefinition(id);
|
||||
message.success('删除成功');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartFlow = async (record: WorkflowDefinition) => {
|
||||
try {
|
||||
await service.startWorkflowInstance(record.key, record.category);
|
||||
message.success('流程启动成功');
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '流程名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '流程标识',
|
||||
dataIndex: 'key',
|
||||
key: 'key',
|
||||
},
|
||||
{
|
||||
title: '流程分类',
|
||||
dataIndex: 'category',
|
||||
key: 'category',
|
||||
render: (category: string) => {
|
||||
const categoryInfo = categories.find(c => c.code === category);
|
||||
return categoryInfo?.label || category;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '触发方式',
|
||||
dataIndex: 'triggers',
|
||||
key: 'triggers',
|
||||
render: (triggers: string[], record: WorkflowDefinition) => {
|
||||
const categoryInfo = categories.find(c => c.code === record.category);
|
||||
return (triggers || [])?.map(triggerCode => {
|
||||
const triggerInfo = categoryInfo?.supportedTriggers?.find(t => t.code === triggerCode);
|
||||
return (
|
||||
<Tag key={triggerCode}>
|
||||
{triggerInfo?.label || triggerCode}
|
||||
</Tag>
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '版本',
|
||||
dataIndex: 'flowVersion',
|
||||
key: 'flowVersion',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'DRAFT' ? 'orange' : 'green'}>
|
||||
{status === 'DRAFT' ? '草稿' : '已发布'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '描述',
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right' as const,
|
||||
width: 220,
|
||||
render: (_: any, record: WorkflowDefinition) => (
|
||||
<Space size="middle">
|
||||
{record.status === 'DRAFT' && (
|
||||
<>
|
||||
<a onClick={() => handleEditFlow(record)}>编辑</a>
|
||||
<a onClick={() => handleDesignFlow(record)}>设计(React Flow)</a>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'DRAFT' && (
|
||||
<a onClick={() => handleDeploy(record.id)}>发布</a>
|
||||
)}
|
||||
<a onClick={() => handleDelete(record.id)}>删除</a>
|
||||
{record.status !== 'DRAFT' && (
|
||||
<a onClick={() => handleStartFlow(record)}>启动</a>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>工作流定义管理 (React Flow版本)</span>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreateFlow}>
|
||||
新建流程
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pageData?.content}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={{
|
||||
current: (query.pageNum || 0) + 1,
|
||||
pageSize: query.pageSize,
|
||||
total: pageData?.totalElements || 0,
|
||||
onChange: (page, pageSize) => setQuery({
|
||||
...query,
|
||||
pageNum: page - 1,
|
||||
pageSize
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
<EditModal
|
||||
visible={modalVisible}
|
||||
onClose={handleModalClose}
|
||||
onSuccess={() => loadData(query)}
|
||||
record={currentRecord}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowDefinitionList;
|
||||
@ -1,81 +0,0 @@
|
||||
import request from '@/utils/request';
|
||||
import type { WorkflowDefinition, WorkflowDefinitionQuery, WorkflowCategory } from './types';
|
||||
import type { Page } from '@/types/base';
|
||||
|
||||
// API 基础路径
|
||||
const DEFINITION_URL = '/api/v1/workflow/definition';
|
||||
|
||||
/**
|
||||
* 获取工作流定义列表
|
||||
*/
|
||||
export const getDefinitions = (params?: WorkflowDefinitionQuery) =>
|
||||
request.get<Page<WorkflowDefinition>>(`${DEFINITION_URL}/page`, { params });
|
||||
|
||||
/**
|
||||
* 获取工作流定义详情
|
||||
*/
|
||||
export const getDefinitionDetail = (id: number) =>
|
||||
request.get<WorkflowDefinition>(`${DEFINITION_URL}/${id}`);
|
||||
|
||||
/**
|
||||
* 获取已发布的工作流定义
|
||||
*/
|
||||
export const getPublishedDefinitions = () =>
|
||||
request.get<WorkflowDefinition[]>(`${DEFINITION_URL}/published`);
|
||||
|
||||
/**
|
||||
* 保存工作流定义(创建)
|
||||
*/
|
||||
export const saveDefinition = (data: WorkflowDefinition) =>
|
||||
request.post<WorkflowDefinition>(DEFINITION_URL, data);
|
||||
|
||||
/**
|
||||
* 更新工作流定义
|
||||
*/
|
||||
export const updateDefinition = (id: number, data: Partial<WorkflowDefinition>) =>
|
||||
request.put<WorkflowDefinition>(`${DEFINITION_URL}/${id}`, data);
|
||||
|
||||
/**
|
||||
* 删除工作流定义
|
||||
*/
|
||||
export const deleteDefinition = (id: number) =>
|
||||
request.delete(`${DEFINITION_URL}/${id}`);
|
||||
|
||||
/**
|
||||
* 发布工作流定义
|
||||
*/
|
||||
export const publishDefinition = (id: number) =>
|
||||
request.post<void>(`${DEFINITION_URL}/${id}/published`);
|
||||
|
||||
/**
|
||||
* 部署工作流定义
|
||||
*/
|
||||
export const deployDefinition = (id: number) =>
|
||||
request.post<void>(`${DEFINITION_URL}/${id}/deploy`);
|
||||
|
||||
/**
|
||||
* 启动工作流实例
|
||||
*/
|
||||
export const startWorkflowInstance = (processKey: string, categoryCode: string) =>
|
||||
request.post<void>(`/api/v1/workflow/instance/start`, {
|
||||
processKey,
|
||||
businessKey: `${categoryCode}_${Date.now()}`,
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取工作流分类
|
||||
*/
|
||||
export const getWorkflowCategories = () =>
|
||||
request.get<WorkflowCategory[]>(`${DEFINITION_URL}/categories`);
|
||||
|
||||
/**
|
||||
* 保存工作流图形数据
|
||||
*/
|
||||
export const saveWorkflowGraph = (id: number, graphData: any) =>
|
||||
request.put<void>(`${DEFINITION_URL}/${id}/graph`, graphData);
|
||||
|
||||
/**
|
||||
* 获取工作流图形数据
|
||||
*/
|
||||
export const getWorkflowGraph = (id: number) =>
|
||||
request.get<any>(`${DEFINITION_URL}/${id}/graph`);
|
||||
@ -1,93 +0,0 @@
|
||||
import { BaseResponse, BaseQuery } from '@/types/base';
|
||||
|
||||
// 复用现有的工作流定义类型,保持API兼容性
|
||||
export interface WorkflowDefinition {
|
||||
id: number;
|
||||
createTime: string | null;
|
||||
createBy: string | null;
|
||||
updateTime: string | null;
|
||||
updateBy: string | null;
|
||||
version: number;
|
||||
deleted: boolean;
|
||||
extraData: any;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
category: string;
|
||||
triggers: string[];
|
||||
flowVersion: number;
|
||||
bpmnXml: string | null;
|
||||
status: string;
|
||||
graph: {
|
||||
nodes: WorkflowDefinitionNode[];
|
||||
edges: WorkflowDefinitionEdge[];
|
||||
};
|
||||
formVariablesSchema?: {
|
||||
type: string;
|
||||
required?: string[];
|
||||
properties: {
|
||||
[key: string]: {
|
||||
type: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
dataSource?: {
|
||||
url: string;
|
||||
type: string;
|
||||
params?: Record<string, any>;
|
||||
dependsOn?: string[];
|
||||
labelField?: string;
|
||||
valueField?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionNode {
|
||||
id: string;
|
||||
nodeCode: string;
|
||||
nodeType: string;
|
||||
nodeName: string;
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
configs: Record<string, any>;
|
||||
inputMapping: Record<string, any>;
|
||||
outputMapping: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionEdge {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
name: string;
|
||||
config: {
|
||||
type: string;
|
||||
};
|
||||
vertices: any[];
|
||||
}
|
||||
|
||||
export interface WorkflowDefinitionQuery extends BaseQuery {
|
||||
name?: string;
|
||||
key?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流触发器类型
|
||||
*/
|
||||
export interface WorkflowTrigger {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作流分类
|
||||
*/
|
||||
export interface WorkflowCategory {
|
||||
code: string;
|
||||
label: string;
|
||||
description: string;
|
||||
supportedTriggers: WorkflowTrigger[];
|
||||
}
|
||||
@ -1,368 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Drawer, Tabs, Button, Space, message } from 'antd';
|
||||
import { SaveOutlined, ReloadOutlined, CloseOutlined } from '@ant-design/icons';
|
||||
import { FormItem, Input, NumberPicker, Select, FormLayout, Switch } from '@formily/antd-v5';
|
||||
import { createForm } from '@formily/core';
|
||||
import { createSchemaField, FormProvider, ISchema } from '@formily/react';
|
||||
import type { FlowNode, FlowNodeData } from '../types';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
import { isConfigurableNode } from '../nodes/types';
|
||||
|
||||
// 创建Schema组件
|
||||
const SchemaField = createSchemaField({
|
||||
components: {
|
||||
FormItem,
|
||||
Input,
|
||||
NumberPicker,
|
||||
Select,
|
||||
FormLayout,
|
||||
Switch,
|
||||
'Input.TextArea': Input.TextArea,
|
||||
},
|
||||
});
|
||||
|
||||
interface NodeConfigModalProps {
|
||||
visible: boolean;
|
||||
node: FlowNode | null;
|
||||
onCancel: () => void;
|
||||
onOk: (nodeId: string, updatedData: Partial<FlowNodeData>) => void;
|
||||
}
|
||||
|
||||
const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
visible,
|
||||
node,
|
||||
onCancel,
|
||||
onOk
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
|
||||
// 获取节点定义
|
||||
const nodeDefinition: WorkflowNodeDefinition | null = node?.data?.nodeDefinition || null;
|
||||
|
||||
// 创建Formily表单实例
|
||||
const configForm = useMemo(() => createForm(), []);
|
||||
const inputForm = useMemo(() => createForm(), []);
|
||||
const outputForm = useMemo(() => createForm(), []);
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
if (visible && node && nodeDefinition) {
|
||||
const nodeData = node.data || {};
|
||||
|
||||
// 准备默认配置
|
||||
const defaultConfig = {
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description
|
||||
};
|
||||
|
||||
// 设置表单初始值
|
||||
configForm.setInitialValues({ ...defaultConfig, ...(nodeData.configs || {}) });
|
||||
configForm.reset();
|
||||
|
||||
if (isConfigurableNode(nodeDefinition)) {
|
||||
inputForm.setInitialValues(nodeData.inputMapping || {});
|
||||
inputForm.reset();
|
||||
|
||||
outputForm.setInitialValues(nodeData.outputMapping || {});
|
||||
outputForm.reset();
|
||||
}
|
||||
}
|
||||
}, [visible, node, nodeDefinition, configForm, inputForm, outputForm]);
|
||||
|
||||
// 递归处理表单值,将JSON字符串转换为对象
|
||||
const processFormValues = (values: Record<string, any>, schema: ISchema | undefined): Record<string, any> => {
|
||||
const result: Record<string, any> = {};
|
||||
|
||||
if (!schema?.properties || typeof schema.properties !== 'object') return values;
|
||||
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const propSchema = (schema.properties as Record<string, any>)?.[key];
|
||||
|
||||
// 如果是object类型且值是字符串,尝试解析
|
||||
if (propSchema?.type === 'object' && typeof value === 'string') {
|
||||
try {
|
||||
result[key] = JSON.parse(value);
|
||||
} catch {
|
||||
result[key] = value; // 解析失败保持原值
|
||||
}
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!node || !nodeDefinition) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 获取表单值并转换
|
||||
const configs = processFormValues(configForm.values, nodeDefinition.configSchema);
|
||||
const inputMapping = isConfigurableNode(nodeDefinition)
|
||||
? processFormValues(inputForm.values, nodeDefinition.inputMappingSchema)
|
||||
: {};
|
||||
const outputMapping = isConfigurableNode(nodeDefinition)
|
||||
? processFormValues(outputForm.values, nodeDefinition.outputMappingSchema)
|
||||
: {};
|
||||
|
||||
const updatedData: Partial<FlowNodeData> = {
|
||||
label: configs.nodeName || node.data.label,
|
||||
configs,
|
||||
inputMapping,
|
||||
outputMapping,
|
||||
};
|
||||
|
||||
onOk(node.id, updatedData);
|
||||
message.success('节点配置保存成功');
|
||||
onCancel();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
configForm.reset();
|
||||
inputForm.reset();
|
||||
outputForm.reset();
|
||||
message.info('已重置为初始值');
|
||||
};
|
||||
|
||||
// 将JSON Schema转换为Formily Schema(扩展配置)
|
||||
const convertToFormilySchema = (jsonSchema: ISchema): ISchema => {
|
||||
const schema: ISchema = {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
};
|
||||
|
||||
if (!jsonSchema.properties || typeof jsonSchema.properties !== 'object') return schema;
|
||||
|
||||
Object.entries(jsonSchema.properties as Record<string, any>).forEach(([key, prop]: [string, any]) => {
|
||||
const field: any = {
|
||||
title: prop.title || key,
|
||||
description: prop.description,
|
||||
'x-decorator': 'FormItem',
|
||||
'x-decorator-props': {
|
||||
tooltip: prop.description,
|
||||
labelCol: 6, // 标签占6列
|
||||
wrapperCol: 18, // 内容占18列(剩余空间)
|
||||
},
|
||||
};
|
||||
|
||||
// 根据类型设置组件
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
if (prop.enum) {
|
||||
field['x-component'] = 'Select';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' }, // 统一宽度
|
||||
options: prop.enum.map((v: any, i: number) => ({
|
||||
label: prop.enumNames?.[i] || v,
|
||||
value: v
|
||||
}))
|
||||
};
|
||||
} else if (prop.format === 'password') {
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
type: 'password',
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
} else {
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'number':
|
||||
case 'integer':
|
||||
field['x-component'] = 'NumberPicker';
|
||||
field['x-component-props'] = {
|
||||
min: prop.minimum,
|
||||
max: prop.maximum,
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
break;
|
||||
case 'boolean':
|
||||
field['x-component'] = 'Switch';
|
||||
break;
|
||||
case 'object':
|
||||
field['x-component'] = 'Input.TextArea';
|
||||
field['x-component-props'] = {
|
||||
rows: 4,
|
||||
style: { width: '100%' }, // 统一宽度
|
||||
placeholder: '请输入JSON格式,例如:{"key": "value"}'
|
||||
};
|
||||
// ✅ 关键修复:将object类型的default值转换为JSON字符串
|
||||
if (prop.default !== undefined && typeof prop.default === 'object') {
|
||||
field.default = JSON.stringify(prop.default, null, 2);
|
||||
} else {
|
||||
field.default = prop.default;
|
||||
}
|
||||
// Formily会自动处理object的序列化
|
||||
field['x-validator'] = (value: any) => {
|
||||
if (!value) return true;
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return true;
|
||||
} catch {
|
||||
return '请输入有效的JSON格式';
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
break;
|
||||
default:
|
||||
field['x-component'] = 'Input';
|
||||
field['x-component-props'] = {
|
||||
style: { width: '100%' } // 统一宽度
|
||||
};
|
||||
}
|
||||
|
||||
// 设置默认值(非object类型)
|
||||
if (prop.type !== 'object' && prop.default !== undefined) {
|
||||
field.default = prop.default;
|
||||
}
|
||||
|
||||
// 设置必填
|
||||
if (Array.isArray(jsonSchema.required) && jsonSchema.required.includes(key)) {
|
||||
field.required = true;
|
||||
}
|
||||
|
||||
(schema.properties as Record<string, any>)[key] = field;
|
||||
});
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
if (!nodeDefinition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 构建Tabs配置
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'config',
|
||||
label: '基本配置',
|
||||
children: activeTab === 'config' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<FormProvider form={configForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.configSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
// 如果是可配置节点,添加输入和输出映射TAB
|
||||
if (isConfigurableNode(nodeDefinition)) {
|
||||
if (nodeDefinition.inputMappingSchema) {
|
||||
tabItems.push({
|
||||
key: 'input',
|
||||
label: '输入映射',
|
||||
children: activeTab === 'input' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<FormProvider form={inputForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.inputMappingSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
|
||||
if (nodeDefinition.outputMappingSchema) {
|
||||
tabItems.push({
|
||||
key: 'output',
|
||||
label: '输出映射',
|
||||
children: activeTab === 'output' ? (
|
||||
<div style={{ padding: '16px 0' }}>
|
||||
<FormProvider form={outputForm}>
|
||||
<FormLayout labelCol={6} wrapperCol={18} labelAlign="right" colon={false}>
|
||||
<SchemaField schema={convertToFormilySchema(nodeDefinition.outputMappingSchema)} />
|
||||
</FormLayout>
|
||||
</FormProvider>
|
||||
</div>
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', paddingRight: '24px' }}>
|
||||
<span style={{ fontSize: '16px', fontWeight: '600' }}>
|
||||
编辑节点 - {nodeDefinition.nodeName}
|
||||
</span>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onCancel}
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
placement="right"
|
||||
width={720}
|
||||
open={visible}
|
||||
onClose={onCancel}
|
||||
closeIcon={null}
|
||||
styles={{
|
||||
body: { padding: '0 24px 24px' },
|
||||
header: { borderBottom: '1px solid #f0f0f0', padding: '16px 24px' }
|
||||
}}
|
||||
footer={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 0',
|
||||
borderTop: '1px solid #f0f0f0'
|
||||
}}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={handleReset}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
icon={<SaveOutlined />}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
保存配置
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ paddingTop: '16px' }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={tabItems}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeConfigModal;
|
||||
@ -1,188 +0,0 @@
|
||||
import React from 'react';
|
||||
import { NodeCategory } from '../types';
|
||||
import { NODE_DEFINITIONS } from '../nodes';
|
||||
import type { WorkflowNodeDefinition } from '../nodes/types';
|
||||
|
||||
// 图标映射函数
|
||||
const getNodeIcon = (iconName: string): string => {
|
||||
const iconMap: Record<string, string> = {
|
||||
'play-circle': '▶️',
|
||||
'stop-circle': '⏹️',
|
||||
'user': '👤',
|
||||
'api': '⚙️',
|
||||
'code': '📜',
|
||||
'build': '🚀',
|
||||
'jenkins': '🔨',
|
||||
'gateway': '💎'
|
||||
};
|
||||
return iconMap[iconName] || '📋';
|
||||
};
|
||||
|
||||
interface NodePanelProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NodePanel: React.FC<NodePanelProps> = ({ className = '' }) => {
|
||||
// 按分类分组节点
|
||||
const nodesByCategory = NODE_DEFINITIONS.reduce((acc, node) => {
|
||||
if (!acc[node.category]) {
|
||||
acc[node.category] = [];
|
||||
}
|
||||
acc[node.category].push(node);
|
||||
return acc;
|
||||
}, {} as Record<NodeCategory, WorkflowNodeDefinition[]>);
|
||||
|
||||
// 拖拽开始处理
|
||||
const handleDragStart = (event: React.DragEvent, nodeDefinition: WorkflowNodeDefinition) => {
|
||||
event.dataTransfer.setData('application/reactflow', JSON.stringify({
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
nodeDefinition
|
||||
}));
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 渲染节点项
|
||||
const renderNodeItem = (nodeDefinition: WorkflowNodeDefinition) => (
|
||||
<div
|
||||
key={nodeDefinition.nodeCode}
|
||||
draggable
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: 'white',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
marginBottom: '6px'
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb';
|
||||
e.currentTarget.style.borderColor = nodeDefinition.uiConfig.style.fill;
|
||||
e.currentTarget.style.transform = 'translateX(2px)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white';
|
||||
e.currentTarget.style.borderColor = '#e5e7eb';
|
||||
e.currentTarget.style.transform = 'translateX(0)';
|
||||
}}
|
||||
onDragStart={(e) => {
|
||||
e.currentTarget.style.cursor = 'grabbing';
|
||||
handleDragStart(e, nodeDefinition);
|
||||
}}
|
||||
onDragEnd={(e) => {
|
||||
e.currentTarget.style.cursor = 'grab';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
width: '20px',
|
||||
textAlign: 'center',
|
||||
color: nodeDefinition.uiConfig.style.fill
|
||||
}}>
|
||||
{getNodeIcon(nodeDefinition.uiConfig.style.icon)}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{
|
||||
fontSize: '13px',
|
||||
fontWeight: '500',
|
||||
color: '#374151',
|
||||
lineHeight: '1.2'
|
||||
}}>
|
||||
{nodeDefinition.nodeName}
|
||||
</div>
|
||||
<div style={{
|
||||
fontSize: '11px',
|
||||
color: '#6b7280',
|
||||
lineHeight: '1.2',
|
||||
marginTop: '2px'
|
||||
}}>
|
||||
{nodeDefinition.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 分类标题映射
|
||||
const categoryTitles = {
|
||||
[NodeCategory.EVENT]: '🎯 事件节点',
|
||||
[NodeCategory.TASK]: '📋 任务节点',
|
||||
[NodeCategory.GATEWAY]: '🔀 网关节点',
|
||||
[NodeCategory.CONTAINER]: '📦 容器节点'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`node-panel ${className}`} style={{
|
||||
width: '260px',
|
||||
height: '100%',
|
||||
background: '#f8fafc',
|
||||
borderRight: '1px solid #e5e7eb',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '16px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
<h3 style={{
|
||||
margin: 0,
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
节点面板
|
||||
</h3>
|
||||
<p style={{
|
||||
margin: '4px 0 0 0',
|
||||
fontSize: '12px',
|
||||
color: '#6b7280'
|
||||
}}>
|
||||
拖拽节点到画布创建工作流
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
flex: '1',
|
||||
overflow: 'auto',
|
||||
padding: '12px'
|
||||
}}>
|
||||
{Object.entries(nodesByCategory).map(([category, nodes]) => (
|
||||
<div key={category} style={{ marginBottom: '16px' }}>
|
||||
<div style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: '#4b5563',
|
||||
marginBottom: '8px',
|
||||
padding: '4px 0',
|
||||
borderBottom: '1px solid #e5e7eb'
|
||||
}}>
|
||||
{categoryTitles[category as NodeCategory]}
|
||||
</div>
|
||||
{nodes.map(renderNodeItem)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 使用提示 */}
|
||||
<div style={{
|
||||
padding: '12px',
|
||||
background: '#f1f5f9',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
fontSize: '11px',
|
||||
color: '#64748b',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
💡 提示:
|
||||
<br />• 拖拽节点到画布创建
|
||||
<br />• 双击节点进行配置
|
||||
<br />• 连接节点创建流程
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodePanel;
|
||||
@ -1,171 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Button, Divider, Tooltip } from 'antd';
|
||||
import {
|
||||
SaveOutlined,
|
||||
UndoOutlined,
|
||||
RedoOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
ExpandOutlined,
|
||||
ArrowLeftOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface WorkflowToolbarProps {
|
||||
title?: string;
|
||||
onSave?: () => void;
|
||||
onUndo?: () => void;
|
||||
onRedo?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
onFitView?: () => void;
|
||||
onBack?: () => void;
|
||||
canUndo?: boolean;
|
||||
canRedo?: boolean;
|
||||
zoom?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
|
||||
title = '工作流设计器',
|
||||
onSave,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onFitView,
|
||||
onBack,
|
||||
canUndo = false,
|
||||
canRedo = false,
|
||||
zoom = 1,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`workflow-toolbar ${className}`}
|
||||
style={{
|
||||
height: '56px',
|
||||
background: 'white',
|
||||
borderBottom: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 16px',
|
||||
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
>
|
||||
{/* 左侧:返回按钮和标题 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<Tooltip title="返回列表">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={onBack}
|
||||
style={{ color: '#6b7280' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider type="vertical" />
|
||||
<h2 style={{
|
||||
margin: 0,
|
||||
fontSize: '16px',
|
||||
fontWeight: '600',
|
||||
color: '#374151'
|
||||
}}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* 右侧:操作按钮区域 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{/* 撤销/重做 */}
|
||||
<Tooltip title="撤销 (Ctrl+Z)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<UndoOutlined />}
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
size="middle"
|
||||
style={{
|
||||
color: canUndo ? '#374151' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="重做 (Ctrl+Shift+Z)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RedoOutlined />}
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
size="middle"
|
||||
style={{
|
||||
color: canRedo ? '#374151' : '#d1d5db',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Divider type="vertical" style={{ margin: '0 4px' }} />
|
||||
|
||||
{/* 视图操作 */}
|
||||
<Tooltip title="放大 (+)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ZoomInOutlined />}
|
||||
onClick={onZoomIn}
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="缩小 (-)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ZoomOutOutlined />}
|
||||
onClick={onZoomOut}
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="适应视图">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandOutlined />}
|
||||
onClick={onFitView}
|
||||
size="middle"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* 缩放比例显示 */}
|
||||
<div style={{
|
||||
background: '#f8fafc',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px',
|
||||
color: '#475569',
|
||||
fontFamily: 'ui-monospace, monospace',
|
||||
minWidth: '60px',
|
||||
textAlign: 'center',
|
||||
fontWeight: '600',
|
||||
border: '1px solid #e2e8f0',
|
||||
marginLeft: '4px'
|
||||
}}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
|
||||
<Divider type="vertical" style={{ margin: '0 8px' }} />
|
||||
|
||||
{/* 保存按钮(最右侧) */}
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SaveOutlined />}
|
||||
onClick={onSave}
|
||||
size="middle"
|
||||
style={{
|
||||
borderRadius: '6px',
|
||||
fontWeight: '500',
|
||||
boxShadow: '0 2px 4px rgba(59, 130, 246, 0.2)',
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowToolbar;
|
||||
@ -1,267 +0,0 @@
|
||||
// 工作流设计器 - React Flow版本
|
||||
// 变量定义
|
||||
@header-height: 64px;
|
||||
@toolbar-height: 56px;
|
||||
@sidebar-width: 260px;
|
||||
@container-padding: 24px;
|
||||
|
||||
// 主容器 - 覆盖父容器样式
|
||||
.workflow-design-container {
|
||||
// 使用负边距抵消父容器的padding
|
||||
margin: -@container-padding;
|
||||
// 精确计算高度:视口高度 - header高度
|
||||
height: calc(100vh - @header-height);
|
||||
// 补偿左右padding
|
||||
width: calc(100% + @container-padding * 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
position: relative;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
|
||||
// React Flow 样式覆盖
|
||||
.react-flow {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
|
||||
&__renderer {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
// 自定义控制按钮样式
|
||||
&__controls {
|
||||
button {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e5e7eb;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 小地图样式
|
||||
&__minimap {
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
// 内容区域
|
||||
.workflow-content-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
|
||||
// 节点面板区域
|
||||
.node-panel {
|
||||
width: @sidebar-width;
|
||||
background: #f8fafc;
|
||||
border-right: 1px solid #e5e7eb;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
|
||||
|
||||
// 面板头部
|
||||
.panel-header {
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
|
||||
// 节点分组
|
||||
.node-group {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #4b5563;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
// 节点项
|
||||
.node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease-in-out;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&:hover {
|
||||
background: #f9fafb;
|
||||
border-color: var(--node-color, #3b82f6);
|
||||
transform: translateX(2px);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.node-icon {
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.node-info {
|
||||
.node-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.node-desc {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
line-height: 1.2;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 使用提示
|
||||
.panel-tips {
|
||||
padding: 12px;
|
||||
background: #f1f5f9;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 11px;
|
||||
color: #64748b;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
// 画布区域
|
||||
.workflow-canvas-area {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
|
||||
// 画布状态面板
|
||||
.canvas-status {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 响应式适配
|
||||
@media (max-width: 1024px) {
|
||||
.workflow-content-area .node-panel {
|
||||
width: 220px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workflow-content-area {
|
||||
flex-direction: column;
|
||||
|
||||
.node-panel {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 工具栏特殊样式
|
||||
.workflow-toolbar {
|
||||
height: @toolbar-height;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
flex-shrink: 0;
|
||||
|
||||
.toolbar-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
|
||||
.status-item {
|
||||
background: #f3f4f6;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
}
|
||||
|
||||
.react-flow-badge {
|
||||
background: #ecfdf5;
|
||||
color: #065f46;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,617 +0,0 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { message } from 'antd';
|
||||
import { ReactFlowProvider, useReactFlow } from '@xyflow/react';
|
||||
|
||||
import WorkflowToolbar from './components/WorkflowToolbar';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import FlowCanvas from './components/FlowCanvas';
|
||||
import NodeConfigModal from './components/NodeConfigModal';
|
||||
import EdgeConfigModal, { type EdgeCondition } from './components/EdgeConfigModal';
|
||||
import type { FlowNode, FlowEdge, FlowNodeData } from './types';
|
||||
import type { WorkflowNodeDefinition } from './nodes/types';
|
||||
import { NodeType } from './types';
|
||||
import { useWorkflowSave } from './hooks/useWorkflowSave';
|
||||
import { useWorkflowLoad } from './hooks/useWorkflowLoad';
|
||||
import { useHistory } from './hooks/useHistory';
|
||||
|
||||
// 样式
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import './index.less';
|
||||
|
||||
const WorkflowDesignInner: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
getEdges,
|
||||
setEdges,
|
||||
screenToFlowPosition,
|
||||
fitView,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
getZoom
|
||||
} = useReactFlow();
|
||||
|
||||
const [workflowTitle, setWorkflowTitle] = useState('新建工作流');
|
||||
const [currentZoom, setCurrentZoom] = useState(1); // 当前缩放比例
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 当前工作流ID
|
||||
const currentWorkflowId = id ? parseInt(id) : undefined;
|
||||
|
||||
// 节点配置模态框状态
|
||||
const [configModalVisible, setConfigModalVisible] = useState(false);
|
||||
const [configNode, setConfigNode] = useState<FlowNode | null>(null);
|
||||
|
||||
// 边配置模态框状态
|
||||
const [edgeConfigModalVisible, setEdgeConfigModalVisible] = useState(false);
|
||||
const [configEdge, setConfigEdge] = useState<FlowEdge | null>(null);
|
||||
|
||||
// 保存和加载hooks
|
||||
const { hasUnsavedChanges, saveWorkflow, markUnsaved } = useWorkflowSave();
|
||||
const { workflowDefinition, loadWorkflow } = useWorkflowLoad();
|
||||
|
||||
// 历史记录管理
|
||||
const history = useHistory();
|
||||
|
||||
// 剪贴板(用于复制粘贴)
|
||||
const clipboard = useRef<{ nodes: FlowNode[]; edges: FlowEdge[] } | null>(null);
|
||||
|
||||
// 加载工作流数据
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (currentWorkflowId) {
|
||||
const data = await loadWorkflow(currentWorkflowId);
|
||||
if (data) {
|
||||
setNodes(data.nodes);
|
||||
setEdges(data.edges);
|
||||
setWorkflowTitle(data.definition.name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [currentWorkflowId, loadWorkflow, setNodes, setEdges]);
|
||||
|
||||
// 初始化缩放比例
|
||||
useEffect(() => {
|
||||
setCurrentZoom(getZoom());
|
||||
}, [getZoom]);
|
||||
|
||||
// 自动适应视图
|
||||
useEffect(() => {
|
||||
// 延迟执行fitView以确保节点已渲染
|
||||
const timer = setTimeout(() => {
|
||||
fitView({
|
||||
padding: 0.1,
|
||||
duration: 800,
|
||||
minZoom: 1.0, // 最小缩放100%
|
||||
maxZoom: 1.0 // 最大缩放100%,确保默认100%
|
||||
});
|
||||
// 更新zoom显示
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [fitView, getZoom]);
|
||||
|
||||
// 初始化示例节点 - 优化位置和布局
|
||||
const initialNodes: FlowNode[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'START_EVENT',
|
||||
position: { x: 250, y: 50 },
|
||||
data: {
|
||||
label: '开始',
|
||||
nodeType: NodeType.START_EVENT,
|
||||
category: 'EVENT' as any,
|
||||
icon: '▶️',
|
||||
color: '#10b981'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'USER_TASK',
|
||||
position: { x: 200, y: 150 },
|
||||
data: {
|
||||
label: '用户审批',
|
||||
nodeType: NodeType.USER_TASK,
|
||||
category: 'TASK' as any,
|
||||
icon: '👤',
|
||||
color: '#6366f1'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'END_EVENT',
|
||||
position: { x: 250, y: 250 },
|
||||
data: {
|
||||
label: '结束',
|
||||
nodeType: NodeType.END_EVENT,
|
||||
category: 'EVENT' as any,
|
||||
icon: '⏹️',
|
||||
color: '#ef4444'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const initialEdges: FlowEdge[] = [
|
||||
{
|
||||
id: 'e1-2',
|
||||
source: '1',
|
||||
target: '2',
|
||||
type: 'default',
|
||||
animated: true,
|
||||
data: {
|
||||
label: '提交',
|
||||
condition: {
|
||||
type: 'DEFAULT',
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'e2-3',
|
||||
source: '2',
|
||||
target: '3',
|
||||
type: 'default',
|
||||
animated: true,
|
||||
data: {
|
||||
label: '通过',
|
||||
condition: {
|
||||
type: 'DEFAULT',
|
||||
priority: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// 工具栏事件处理
|
||||
const handleSave = useCallback(async () => {
|
||||
const nodes = getNodes() as FlowNode[];
|
||||
const edges = getEdges() as FlowEdge[];
|
||||
|
||||
const success = await saveWorkflow({
|
||||
nodes,
|
||||
edges,
|
||||
workflowId: currentWorkflowId,
|
||||
name: workflowTitle,
|
||||
description: workflowDefinition?.description || '',
|
||||
definitionData: workflowDefinition // 传递原始定义数据
|
||||
});
|
||||
|
||||
if (success) {
|
||||
console.log('保存工作流成功:', { nodes, edges });
|
||||
}
|
||||
}, [getNodes, getEdges, saveWorkflow, currentWorkflowId, workflowTitle, workflowDefinition]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
navigate('/workflow2/definition');
|
||||
}, [navigate]);
|
||||
|
||||
// 撤销操作
|
||||
const handleUndo = useCallback(() => {
|
||||
const state = history.undo();
|
||||
if (state) {
|
||||
history.pauseRecording();
|
||||
setNodes(state.nodes);
|
||||
setEdges(state.edges);
|
||||
setTimeout(() => history.resumeRecording(), 100);
|
||||
message.success('已撤销');
|
||||
} else {
|
||||
message.info('没有可撤销的操作');
|
||||
}
|
||||
}, [history, setNodes, setEdges]);
|
||||
|
||||
// 重做操作
|
||||
const handleRedo = useCallback(() => {
|
||||
const state = history.redo();
|
||||
if (state) {
|
||||
history.pauseRecording();
|
||||
setNodes(state.nodes);
|
||||
setEdges(state.edges);
|
||||
setTimeout(() => history.resumeRecording(), 100);
|
||||
message.success('已重做');
|
||||
} else {
|
||||
message.info('没有可重做的操作');
|
||||
}
|
||||
}, [history, setNodes, setEdges]);
|
||||
|
||||
// 复制选中的节点和边
|
||||
const handleCopy = useCallback(() => {
|
||||
const selectedNodes = getNodes().filter(node => node.selected);
|
||||
const selectedEdges = getEdges().filter(edge => {
|
||||
// 只复制两端都被选中的边
|
||||
return selectedNodes.some(n => n.id === edge.source) &&
|
||||
selectedNodes.some(n => n.id === edge.target);
|
||||
});
|
||||
|
||||
if (selectedNodes.length > 0) {
|
||||
clipboard.current = {
|
||||
nodes: JSON.parse(JSON.stringify(selectedNodes)),
|
||||
edges: JSON.parse(JSON.stringify(selectedEdges))
|
||||
};
|
||||
message.success(`已复制 ${selectedNodes.length} 个节点`);
|
||||
} else {
|
||||
message.warning('请先选择要复制的节点');
|
||||
}
|
||||
}, [getNodes, getEdges]);
|
||||
|
||||
// 粘贴节点
|
||||
const handlePaste = useCallback(() => {
|
||||
if (!clipboard.current || clipboard.current.nodes.length === 0) {
|
||||
message.info('剪贴板为空');
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes: copiedNodes, edges: copiedEdges } = clipboard.current;
|
||||
const offset = 50; // 粘贴偏移量
|
||||
const idMap = new Map<string, string>();
|
||||
|
||||
// 创建新节点(带偏移)
|
||||
const newNodes = copiedNodes.map(node => {
|
||||
const newId = `${node.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
idMap.set(node.id, newId);
|
||||
|
||||
return {
|
||||
...node,
|
||||
id: newId,
|
||||
position: {
|
||||
x: node.position.x + offset,
|
||||
y: node.position.y + offset
|
||||
},
|
||||
selected: true
|
||||
};
|
||||
});
|
||||
|
||||
// 创建新边(更新source和target为新ID)
|
||||
const newEdges = copiedEdges.map(edge => {
|
||||
const newSource = idMap.get(edge.source);
|
||||
const newTarget = idMap.get(edge.target);
|
||||
|
||||
if (!newSource || !newTarget) return null;
|
||||
|
||||
return {
|
||||
...edge,
|
||||
id: `e${newSource}-${newTarget}`,
|
||||
source: newSource,
|
||||
target: newTarget,
|
||||
selected: true
|
||||
};
|
||||
}).filter(edge => edge !== null) as FlowEdge[];
|
||||
|
||||
// 取消其他元素的选中状态
|
||||
setNodes(nodes => [
|
||||
...nodes.map(n => ({ ...n, selected: false })),
|
||||
...newNodes
|
||||
]);
|
||||
setEdges(edges => [
|
||||
...edges.map(e => ({ ...e, selected: false })),
|
||||
...newEdges
|
||||
]);
|
||||
|
||||
message.success(`已粘贴 ${newNodes.length} 个节点`);
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
const selectedNodes = getNodes().filter(node => node.selected);
|
||||
const selectedEdges = getEdges().filter(edge => edge.selected);
|
||||
|
||||
if (selectedNodes.length > 0 || selectedEdges.length > 0) {
|
||||
setNodes(nodes => nodes.filter(node => !node.selected));
|
||||
setEdges(edges => edges.filter(edge => !edge.selected));
|
||||
message.success(`已删除 ${selectedNodes.length} 个节点和 ${selectedEdges.length} 条连接`);
|
||||
markUnsaved();
|
||||
} else {
|
||||
message.warning('请先选择要删除的元素');
|
||||
}
|
||||
}, [getNodes, getEdges, setNodes, setEdges, markUnsaved]);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
setNodes(nodes => nodes.map(node => ({ ...node, selected: true })));
|
||||
setEdges(edges => edges.map(edge => ({ ...edge, selected: true })));
|
||||
message.info('已全选所有元素');
|
||||
}, [setNodes, setEdges]);
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
fitView({ padding: 0.2, duration: 800 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 850);
|
||||
}, [fitView, getZoom]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
zoomIn({ duration: 300 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||||
}, [zoomIn, getZoom]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
zoomOut({ duration: 300 });
|
||||
// 延迟更新zoom值以获取最新的缩放比例
|
||||
setTimeout(() => setCurrentZoom(getZoom()), 350);
|
||||
}, [zoomOut, getZoom]);
|
||||
|
||||
// 处理节点拖拽放置 - 使用官方推荐的screenToFlowPosition方法
|
||||
const handleDrop = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const dragData = event.dataTransfer.getData('application/reactflow');
|
||||
if (!dragData) return;
|
||||
|
||||
try {
|
||||
const { nodeType, nodeDefinition }: { nodeType: string; nodeDefinition: WorkflowNodeDefinition } = JSON.parse(dragData);
|
||||
|
||||
// 根据React Flow官方文档,screenToFlowPosition会自动处理所有边界计算
|
||||
// 不需要手动减去容器边界!
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const newNode: FlowNode = {
|
||||
id: `${nodeType}-${Date.now()}`,
|
||||
type: nodeType,
|
||||
position,
|
||||
data: {
|
||||
label: nodeDefinition.nodeName,
|
||||
nodeType: nodeDefinition.nodeType,
|
||||
category: nodeDefinition.category,
|
||||
icon: nodeDefinition.uiConfig.style.icon,
|
||||
color: nodeDefinition.uiConfig.style.fill,
|
||||
// 保存原始节点定义引用,用于配置
|
||||
nodeDefinition,
|
||||
// 初始化配置数据
|
||||
configs: {
|
||||
nodeName: nodeDefinition.nodeName,
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description
|
||||
},
|
||||
inputMapping: {},
|
||||
outputMapping: {}
|
||||
}
|
||||
};
|
||||
|
||||
setNodes(nodes => [...nodes, newNode]);
|
||||
message.success(`已添加 ${nodeDefinition.nodeName} 节点`);
|
||||
} catch (error) {
|
||||
console.error('解析拖拽数据失败:', error);
|
||||
message.error('添加节点失败');
|
||||
}
|
||||
}, [screenToFlowPosition, setNodes]);
|
||||
|
||||
// 处理节点双击 - 打开配置面板
|
||||
const handleNodeClick = useCallback((event: React.MouseEvent, node: FlowNode) => {
|
||||
// 只处理双击事件
|
||||
if (event.detail === 2) {
|
||||
console.log('双击节点,打开配置:', node);
|
||||
setConfigNode(node);
|
||||
setConfigModalVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理边双击 - 打开条件配置弹窗
|
||||
const handleEdgeClick = useCallback((event: React.MouseEvent, edge: FlowEdge) => {
|
||||
if (event.detail === 2) {
|
||||
console.log('双击边,打开配置:', edge);
|
||||
setConfigEdge(edge);
|
||||
setEdgeConfigModalVisible(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理节点配置更新
|
||||
const handleNodeConfigUpdate = useCallback((nodeId: string, updatedData: Partial<FlowNodeData>) => {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
...updatedData,
|
||||
},
|
||||
}
|
||||
: node
|
||||
)
|
||||
);
|
||||
markUnsaved(); // 标记有未保存更改
|
||||
}, [setNodes, markUnsaved]);
|
||||
|
||||
// 关闭节点配置模态框
|
||||
const handleCloseConfigModal = useCallback(() => {
|
||||
setConfigModalVisible(false);
|
||||
setConfigNode(null);
|
||||
}, []);
|
||||
|
||||
// 处理边条件更新
|
||||
const handleEdgeConditionUpdate = useCallback((edgeId: string, condition: EdgeCondition) => {
|
||||
setEdges((edges) =>
|
||||
edges.map((edge) => {
|
||||
if (edge.id === edgeId) {
|
||||
// 根据条件类型生成显示文本
|
||||
const label = condition.type === 'EXPRESSION'
|
||||
? condition.expression
|
||||
: '默认路径';
|
||||
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
condition,
|
||||
},
|
||||
label,
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
})
|
||||
);
|
||||
markUnsaved(); // 标记有未保存更改
|
||||
message.success('边条件配置已更新');
|
||||
setEdgeConfigModalVisible(false);
|
||||
setConfigEdge(null);
|
||||
}, [setEdges, markUnsaved]);
|
||||
|
||||
// 关闭边配置模态框
|
||||
const handleCloseEdgeConfigModal = useCallback(() => {
|
||||
setEdgeConfigModalVisible(false);
|
||||
setConfigEdge(null);
|
||||
}, []);
|
||||
|
||||
// 监听视图变化(缩放、平移等)
|
||||
const handleViewportChange = useCallback(() => {
|
||||
const zoom = getZoom();
|
||||
setCurrentZoom(zoom);
|
||||
}, [getZoom]);
|
||||
|
||||
// 监听节点和边的变化,记录到历史
|
||||
useEffect(() => {
|
||||
const nodes = getNodes() as FlowNode[];
|
||||
const edges = getEdges() as FlowEdge[];
|
||||
|
||||
// 只在有节点或边时记录(避免空状态)
|
||||
if (nodes.length > 0 || edges.length > 0) {
|
||||
history.record(nodes, edges);
|
||||
}
|
||||
}, [getNodes, getEdges, history]);
|
||||
|
||||
// 键盘快捷键支持
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 检查焦点是否在输入框、文本域或可编辑元素内
|
||||
const target = e.target as HTMLElement;
|
||||
const tagName = target.tagName?.toUpperCase();
|
||||
const isInputElement = tagName === 'INPUT' ||
|
||||
tagName === 'TEXTAREA' ||
|
||||
target.isContentEditable ||
|
||||
target.getAttribute('contenteditable') === 'true';
|
||||
const isInDrawer = target.closest('.ant-drawer-body') !== null;
|
||||
const isInModal = target.closest('.ant-modal') !== null;
|
||||
|
||||
// 在抽屉或模态框内,且在输入元素中时,允许原生行为
|
||||
const shouldSkipShortcut = isInputElement || isInDrawer || isInModal;
|
||||
|
||||
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
|
||||
const ctrlKey = isMac ? e.metaKey : e.ctrlKey;
|
||||
|
||||
// Ctrl+Z / Cmd+Z - 撤销(仅在画布区域)
|
||||
if (ctrlKey && e.key === 'z' && !e.shiftKey) {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleUndo();
|
||||
}
|
||||
}
|
||||
// Ctrl+Shift+Z / Cmd+Shift+Z - 重做(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'z' && e.shiftKey) {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleRedo();
|
||||
}
|
||||
}
|
||||
// Ctrl+C / Cmd+C - 复制节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'c') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleCopy();
|
||||
}
|
||||
}
|
||||
// Ctrl+V / Cmd+V - 粘贴节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'v') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
}
|
||||
}
|
||||
// Ctrl+A / Cmd+A - 全选节点(仅在画布区域)
|
||||
else if (ctrlKey && e.key === 'a') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleSelectAll();
|
||||
}
|
||||
}
|
||||
// Delete / Backspace - 删除节点(仅在画布区域)
|
||||
else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
if (!shouldSkipShortcut) {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleUndo, handleRedo, handleCopy, handlePaste, handleSelectAll, handleDelete]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="workflow-design-container"
|
||||
style={{
|
||||
// 确保覆盖父容器的overflow设置
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<WorkflowToolbar
|
||||
title={`${workflowTitle}${hasUnsavedChanges ? ' *' : ''}`}
|
||||
onSave={handleSave}
|
||||
onBack={handleBack}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
onFitView={handleFitView}
|
||||
canUndo={history.canUndo}
|
||||
canRedo={history.canRedo}
|
||||
zoom={currentZoom}
|
||||
/>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="workflow-content-area">
|
||||
{/* 节点面板 */}
|
||||
<NodePanel />
|
||||
|
||||
{/* 画布区域 */}
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className="workflow-canvas-area"
|
||||
>
|
||||
<FlowCanvas
|
||||
initialNodes={initialNodes}
|
||||
initialEdges={initialEdges}
|
||||
onNodeClick={handleNodeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
onDrop={handleDrop}
|
||||
onViewportChange={handleViewportChange}
|
||||
className="workflow-canvas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 节点配置弹窗 */}
|
||||
<NodeConfigModal
|
||||
visible={configModalVisible}
|
||||
node={configNode}
|
||||
onCancel={handleCloseConfigModal}
|
||||
onOk={handleNodeConfigUpdate}
|
||||
/>
|
||||
|
||||
{/* 边条件配置弹窗 */}
|
||||
<EdgeConfigModal
|
||||
visible={edgeConfigModalVisible}
|
||||
edge={configEdge}
|
||||
onOk={handleEdgeConditionUpdate}
|
||||
onCancel={handleCloseEdgeConfigModal}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkflowDesign: React.FC = () => {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<WorkflowDesignInner />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowDesign;
|
||||
@ -1,117 +0,0 @@
|
||||
// 节点分类
|
||||
export enum NodeCategory {
|
||||
EVENT = 'EVENT',
|
||||
TASK = 'TASK',
|
||||
GATEWAY = 'GATEWAY',
|
||||
CONTAINER = 'CONTAINER'
|
||||
}
|
||||
|
||||
// 节点类型(完整定义,包含所有可能的类型)
|
||||
export enum NodeType {
|
||||
START_EVENT = 'START_EVENT',
|
||||
END_EVENT = 'END_EVENT',
|
||||
USER_TASK = 'USER_TASK',
|
||||
SERVICE_TASK = 'SERVICE_TASK',
|
||||
SCRIPT_TASK = 'SCRIPT_TASK',
|
||||
DEPLOY_NODE = 'DEPLOY_NODE',
|
||||
JENKINS_BUILD = 'JENKINS_BUILD',
|
||||
GATEWAY_NODE = 'GATEWAY_NODE',
|
||||
SUB_PROCESS = 'SUB_PROCESS',
|
||||
CALL_ACTIVITY = 'CALL_ACTIVITY'
|
||||
}
|
||||
|
||||
// 节点类型到分类的映射
|
||||
export const NODE_CATEGORY_MAP: Record<NodeType, NodeCategory> = {
|
||||
[NodeType.START_EVENT]: NodeCategory.EVENT,
|
||||
[NodeType.END_EVENT]: NodeCategory.EVENT,
|
||||
[NodeType.USER_TASK]: NodeCategory.TASK,
|
||||
[NodeType.SERVICE_TASK]: NodeCategory.TASK,
|
||||
[NodeType.SCRIPT_TASK]: NodeCategory.TASK,
|
||||
[NodeType.DEPLOY_NODE]: NodeCategory.TASK,
|
||||
[NodeType.JENKINS_BUILD]: NodeCategory.TASK,
|
||||
[NodeType.GATEWAY_NODE]: NodeCategory.GATEWAY,
|
||||
[NodeType.SUB_PROCESS]: NodeCategory.CONTAINER,
|
||||
[NodeType.CALL_ACTIVITY]: NodeCategory.CONTAINER,
|
||||
};
|
||||
|
||||
// 获取节点分类的工具函数
|
||||
export const getNodeCategory = (nodeType: NodeType | string): NodeCategory => {
|
||||
return NODE_CATEGORY_MAP[nodeType as NodeType] || NodeCategory.TASK;
|
||||
};
|
||||
|
||||
// JSON Schema 定义
|
||||
/**
|
||||
* JSON Schema 接口 - 直接使用 Formily 官方提供的 ISchema 类型
|
||||
* 从 @formily/react 导入(它会从 @formily/json-schema 重新导出)
|
||||
*/
|
||||
import type { ISchema } from '@formily/react';
|
||||
export type JSONSchema = ISchema;
|
||||
|
||||
// UI 配置
|
||||
export interface NodeSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface NodeStyle {
|
||||
fill: string;
|
||||
icon: string;
|
||||
stroke: string;
|
||||
iconColor: string;
|
||||
strokeWidth: number;
|
||||
iconSize?: number;
|
||||
borderRadius?: string;
|
||||
boxShadow?: string;
|
||||
transition?: string;
|
||||
fontSize?: string;
|
||||
fontWeight?: string;
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export interface UIConfig {
|
||||
size: NodeSize;
|
||||
style: NodeStyle;
|
||||
}
|
||||
|
||||
// 基础节点定义(只有基本配置)
|
||||
export interface BaseNodeDefinition {
|
||||
nodeCode: string;
|
||||
nodeName: string;
|
||||
nodeType: NodeType;
|
||||
category: NodeCategory;
|
||||
description: string;
|
||||
uiConfig: UIConfig;
|
||||
configSchema: JSONSchema; // 基本配置Schema(包含基本信息+节点配置)
|
||||
}
|
||||
|
||||
// 可配置节点定义(有3个TAB:基本配置、输入、输出)
|
||||
export interface ConfigurableNodeDefinition extends BaseNodeDefinition {
|
||||
inputMappingSchema?: JSONSchema; // 输入映射的Schema定义
|
||||
outputMappingSchema?: JSONSchema; // 输出映射的Schema定义
|
||||
}
|
||||
|
||||
// 工作流节点定义(联合类型)
|
||||
export type WorkflowNodeDefinition = BaseNodeDefinition | ConfigurableNodeDefinition;
|
||||
|
||||
// 节点实例数据(运行时)
|
||||
export interface NodeInstanceData {
|
||||
nodeCode: string;
|
||||
nodeName: string;
|
||||
nodeType: NodeType;
|
||||
category: NodeCategory;
|
||||
description?: string;
|
||||
|
||||
// 运行时数据(key/value格式)
|
||||
configs?: Record<string, any>; // 基本配置数据(包含基本信息+节点配置)
|
||||
inputMapping?: Record<string, any>;
|
||||
outputMapping?: Record<string, any>;
|
||||
|
||||
// UI位置信息
|
||||
position: { x: number; y: number };
|
||||
uiConfig: UIConfig;
|
||||
}
|
||||
|
||||
// 判断是否为可配置节点
|
||||
export const isConfigurableNode = (def: WorkflowNodeDefinition): def is ConfigurableNodeDefinition => {
|
||||
return 'inputMappingSchema' in def || 'outputMappingSchema' in def;
|
||||
};
|
||||
@ -1,207 +0,0 @@
|
||||
import type { FlowNode, FlowEdge } from '../types';
|
||||
import { NodeType } from '../types';
|
||||
|
||||
/**
|
||||
* 验证结果接口
|
||||
*/
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证工作流是否为空
|
||||
*/
|
||||
const validateNotEmpty = (nodes: FlowNode[]): ValidationResult => {
|
||||
if (nodes.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '流程图中没有任何节点,请至少添加一个节点'
|
||||
};
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证必需的开始和结束节点
|
||||
*/
|
||||
const validateRequiredNodes = (nodes: FlowNode[]): ValidationResult => {
|
||||
// 检查开始节点
|
||||
const hasStartNode = nodes.some(node => node.data.nodeType === NodeType.START_EVENT);
|
||||
if (!hasStartNode) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '流程图中必须包含开始节点'
|
||||
};
|
||||
}
|
||||
|
||||
// 检查结束节点
|
||||
const hasEndNode = nodes.some(node => node.data.nodeType === NodeType.END_EVENT);
|
||||
if (!hasEndNode) {
|
||||
return {
|
||||
valid: false,
|
||||
message: '流程图中必须包含结束节点'
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证节点连接完整性
|
||||
* 检查是否有孤立的节点(除了开始和结束节点可能只有单向连接)
|
||||
*/
|
||||
const validateNodeConnections = (nodes: FlowNode[], edges: FlowEdge[]): ValidationResult => {
|
||||
if (nodes.length <= 1) {
|
||||
return { valid: true }; // 单节点或空流程不需要验证连接
|
||||
}
|
||||
|
||||
// 构建节点连接映射
|
||||
const nodeConnections = new Map<string, { incoming: number; outgoing: number }>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
nodeConnections.set(node.id, { incoming: 0, outgoing: 0 });
|
||||
});
|
||||
|
||||
edges.forEach(edge => {
|
||||
const source = nodeConnections.get(edge.source);
|
||||
const target = nodeConnections.get(edge.target);
|
||||
|
||||
if (source) source.outgoing++;
|
||||
if (target) target.incoming++;
|
||||
});
|
||||
|
||||
// 检查每个节点的连接情况
|
||||
for (const [nodeId, connections] of nodeConnections.entries()) {
|
||||
const node = nodes.find(n => n.id === nodeId);
|
||||
if (!node) continue;
|
||||
|
||||
const nodeType = node.data.nodeType;
|
||||
|
||||
// 开始节点必须有出边
|
||||
if (nodeType === NodeType.START_EVENT && connections.outgoing === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `开始节点"${node.data.label}"没有连接到任何其他节点`
|
||||
};
|
||||
}
|
||||
|
||||
// 结束节点必须有入边
|
||||
if (nodeType === NodeType.END_EVENT && connections.incoming === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `结束节点"${node.data.label}"没有被任何节点连接`
|
||||
};
|
||||
}
|
||||
|
||||
// 中间节点必须既有入边又有出边
|
||||
if (nodeType !== NodeType.START_EVENT && nodeType !== NodeType.END_EVENT) {
|
||||
if (connections.incoming === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"没有输入连接,请确保它被其他节点连接`
|
||||
};
|
||||
}
|
||||
if (connections.outgoing === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"没有输出连接,请确保它连接到其他节点`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证节点配置完整性
|
||||
* 检查节点是否有必填配置
|
||||
*/
|
||||
const validateNodeConfigs = (nodes: FlowNode[]): ValidationResult => {
|
||||
for (const node of nodes) {
|
||||
// 检查节点是否有配置数据
|
||||
if (!node.data.configs) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"缺少配置信息,请配置该节点`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查节点名称
|
||||
if (!node.data.configs.nodeName || node.data.configs.nodeName.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"的名称不能为空`
|
||||
};
|
||||
}
|
||||
|
||||
// 检查节点编码
|
||||
if (!node.data.configs.nodeCode || node.data.configs.nodeCode.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `节点"${node.data.label}"的编码不能为空`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 验证边条件配置
|
||||
* 如果边配置了条件,确保条件有效
|
||||
*/
|
||||
const validateEdgeConditions = (edges: FlowEdge[]): ValidationResult => {
|
||||
for (const edge of edges) {
|
||||
const condition = edge.data?.condition;
|
||||
|
||||
if (condition) {
|
||||
// 如果是表达式类型,检查表达式是否为空
|
||||
if (condition.type === 'EXPRESSION') {
|
||||
if (!condition.expression || condition.expression.trim() === '') {
|
||||
return {
|
||||
valid: false,
|
||||
message: `边"${edge.id}"配置了表达式条件,但表达式为空`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 检查优先级是否有效
|
||||
if (condition.priority < 1 || condition.priority > 999) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `边"${edge.id}"的优先级必须在1-999之间`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* 完整的工作流验证
|
||||
* 依次执行所有验证规则,任何一个失败就返回失败
|
||||
*/
|
||||
export const validateWorkflow = (nodes: FlowNode[], edges: FlowEdge[]): ValidationResult => {
|
||||
// 定义验证规则链
|
||||
const validators = [
|
||||
() => validateNotEmpty(nodes),
|
||||
() => validateRequiredNodes(nodes),
|
||||
() => validateNodeConnections(nodes, edges),
|
||||
() => validateNodeConfigs(nodes),
|
||||
() => validateEdgeConditions(edges),
|
||||
];
|
||||
|
||||
// 依次执行验证
|
||||
for (const validator of validators) {
|
||||
const result = validator();
|
||||
if (!result.valid) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
@ -1,369 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Card,
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
message,
|
||||
Modal,
|
||||
Row,
|
||||
Col,
|
||||
Statistic
|
||||
} from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
StopOutlined,
|
||||
DeleteOutlined,
|
||||
ReloadOutlined,
|
||||
EyeOutlined,
|
||||
BarChartOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import * as service from './service';
|
||||
import type {
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceQuery,
|
||||
WorkflowInstanceStatus,
|
||||
InstanceStatistics
|
||||
} from './types';
|
||||
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||
|
||||
const { confirm } = Modal;
|
||||
|
||||
const WorkflowInstanceList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pageData, setPageData] = useState<{
|
||||
content: WorkflowInstance[];
|
||||
totalElements: number;
|
||||
size: number;
|
||||
number: number;
|
||||
} | null>(null);
|
||||
const [statistics, setStatistics] = useState<InstanceStatistics | null>(null);
|
||||
const [query, setQuery] = useState<WorkflowInstanceQuery>({
|
||||
pageNum: DEFAULT_CURRENT - 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE
|
||||
});
|
||||
|
||||
// 加载数据
|
||||
const loadData = async (params: WorkflowInstanceQuery) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [instanceData, statsData] = await Promise.all([
|
||||
service.getInstances(params),
|
||||
service.getInstanceStatistics()
|
||||
]);
|
||||
setPageData(instanceData);
|
||||
setStatistics(statsData);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadData(query);
|
||||
}, [query]);
|
||||
|
||||
// 状态标签渲染
|
||||
const renderStatus = (status: WorkflowInstanceStatus) => {
|
||||
const statusConfig = {
|
||||
RUNNING: { color: 'processing', text: '运行中' },
|
||||
COMPLETED: { color: 'success', text: '已完成' },
|
||||
FAILED: { color: 'error', text: '失败' },
|
||||
SUSPENDED: { color: 'warning', text: '已挂起' },
|
||||
TERMINATED: { color: 'default', text: '已终止' }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { color: 'default', text: status };
|
||||
return <Tag color={config.color}>{config.text}</Tag>;
|
||||
};
|
||||
|
||||
// 操作处理
|
||||
const handleSuspend = async (instance: WorkflowInstance) => {
|
||||
confirm({
|
||||
title: '确认挂起',
|
||||
content: `确定要挂起实例 "${instance.workflowName}" 吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await service.suspendInstance(instance.id);
|
||||
message.success('实例已挂起');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleActivate = async (instance: WorkflowInstance) => {
|
||||
try {
|
||||
await service.activateInstance(instance.id);
|
||||
message.success('实例已激活');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminate = async (instance: WorkflowInstance) => {
|
||||
confirm({
|
||||
title: '确认终止',
|
||||
content: `确定要终止实例 "${instance.workflowName}" 吗?此操作不可逆。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await service.terminateInstance(instance.id);
|
||||
message.success('实例已终止');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (instance: WorkflowInstance) => {
|
||||
confirm({
|
||||
title: '确认删除',
|
||||
content: `确定要删除实例 "${instance.workflowName}" 吗?此操作不可逆。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await service.deleteInstance(instance.id);
|
||||
message.success('实例已删除');
|
||||
loadData(query);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 查看详情
|
||||
const handleViewDetail = (instance: WorkflowInstance) => {
|
||||
navigate(`/workflow2/instance/${instance.id}`);
|
||||
};
|
||||
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: '实例ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '工作流名称',
|
||||
dataIndex: 'workflowName',
|
||||
key: 'workflowName',
|
||||
},
|
||||
{
|
||||
title: '业务键',
|
||||
dataIndex: 'businessKey',
|
||||
key: 'businessKey',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: renderStatus,
|
||||
},
|
||||
{
|
||||
title: '启动人',
|
||||
dataIndex: 'startUserName',
|
||||
key: 'startUserName',
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
key: 'startTime',
|
||||
render: (time: string) => dayjs(time).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration: number) => {
|
||||
if (!duration) return '-';
|
||||
const hours = Math.floor(duration / 3600000);
|
||||
const minutes = Math.floor((duration % 3600000) / 60000);
|
||||
const seconds = Math.floor((duration % 60000) / 1000);
|
||||
return `${hours}h ${minutes}m ${seconds}s`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 200,
|
||||
render: (_: any, record: WorkflowInstance) => (
|
||||
<Space size="small">
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => handleViewDetail(record)}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'RUNNING' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PauseCircleOutlined />}
|
||||
onClick={() => handleSuspend(record)}
|
||||
>
|
||||
挂起
|
||||
</Button>
|
||||
)}
|
||||
{record.status === 'SUSPENDED' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<PlayCircleOutlined />}
|
||||
onClick={() => handleActivate(record)}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'RUNNING' || record.status === 'SUSPENDED') && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<StopOutlined />}
|
||||
onClick={() => handleTerminate(record)}
|
||||
>
|
||||
终止
|
||||
</Button>
|
||||
)}
|
||||
{(record.status === 'COMPLETED' || record.status === 'FAILED' || record.status === 'TERMINATED') && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(record)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 统计信息 */}
|
||||
{statistics && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总实例数"
|
||||
value={statistics.total}
|
||||
prefix={<BarChartOutlined />}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="运行中"
|
||||
value={statistics.running}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="已完成"
|
||||
value={statistics.completed}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="失败"
|
||||
value={statistics.failed}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日启动"
|
||||
value={statistics.todayStarted}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="平均耗时"
|
||||
value={Math.round(statistics.avgDuration / 1000)}
|
||||
suffix="秒"
|
||||
valueStyle={{ color: '#fa8c16' }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* 主表格 */}
|
||||
<Card
|
||||
title="工作流实例列表"
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => loadData(query)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={pageData?.content}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{
|
||||
current: (query.pageNum || 0) + 1,
|
||||
pageSize: query.pageSize,
|
||||
total: pageData?.totalElements || 0,
|
||||
onChange: (page, pageSize) => setQuery({
|
||||
...query,
|
||||
pageNum: page - 1,
|
||||
pageSize
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowInstanceList;
|
||||
@ -1,90 +0,0 @@
|
||||
import request from '@/utils/request';
|
||||
import type { Page } from '@/types/base';
|
||||
import type {
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceQuery,
|
||||
TaskInstance,
|
||||
TaskQuery,
|
||||
InstanceStatistics
|
||||
} from './types';
|
||||
|
||||
// 获取工作流实例列表
|
||||
export async function getInstances(params: WorkflowInstanceQuery): Promise<Page<WorkflowInstance>> {
|
||||
return request.get('/api/v1/workflow/instances/page', { params });
|
||||
}
|
||||
|
||||
// 获取单个工作流实例详情
|
||||
export async function getInstanceDetail(id: number): Promise<WorkflowInstance> {
|
||||
return request.get(`/api/v1/workflow/instances/${id}`);
|
||||
}
|
||||
|
||||
// 启动工作流实例
|
||||
export async function startInstance(data: {
|
||||
workflowKey: string;
|
||||
businessKey?: string;
|
||||
variables?: Record<string, any>;
|
||||
}): Promise<WorkflowInstance> {
|
||||
return request.post('/api/v1/workflow/instances/start', data);
|
||||
}
|
||||
|
||||
// 终止工作流实例
|
||||
export async function terminateInstance(id: number, reason?: string): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/instances/${id}/terminate`, { reason });
|
||||
}
|
||||
|
||||
// 挂起工作流实例
|
||||
export async function suspendInstance(id: number, reason?: string): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/instances/${id}/suspend`, { reason });
|
||||
}
|
||||
|
||||
// 激活工作流实例
|
||||
export async function activateInstance(id: number): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/instances/${id}/activate`);
|
||||
}
|
||||
|
||||
// 删除工作流实例
|
||||
export async function deleteInstance(id: number): Promise<void> {
|
||||
return request.delete(`/api/v1/workflow/instances/${id}`);
|
||||
}
|
||||
|
||||
// 获取工作流实例的任务列表
|
||||
export async function getInstanceTasks(instanceId: number): Promise<TaskInstance[]> {
|
||||
return request.get(`/api/v1/workflow/instances/${instanceId}/tasks`);
|
||||
}
|
||||
|
||||
// 获取任务列表
|
||||
export async function getTasks(params: TaskQuery): Promise<Page<TaskInstance>> {
|
||||
return request.get('/api/v1/workflow/tasks/page', { params });
|
||||
}
|
||||
|
||||
// 完成任务
|
||||
export async function completeTask(taskId: number, variables?: Record<string, any>): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/tasks/${taskId}/complete`, { variables });
|
||||
}
|
||||
|
||||
// 委派任务
|
||||
export async function delegateTask(taskId: number, assignee: string): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/tasks/${taskId}/delegate`, { assignee });
|
||||
}
|
||||
|
||||
// 认领任务
|
||||
export async function claimTask(taskId: number): Promise<void> {
|
||||
return request.post(`/api/v1/workflow/tasks/${taskId}/claim`);
|
||||
}
|
||||
|
||||
// 获取实例统计信息
|
||||
export async function getInstanceStatistics(): Promise<InstanceStatistics> {
|
||||
return request.get('/api/v1/workflow/instances/statistics');
|
||||
}
|
||||
|
||||
// 获取实例执行历史
|
||||
export async function getInstanceHistory(instanceId: number): Promise<any[]> {
|
||||
return request.get(`/api/v1/workflow/instances/${instanceId}/history`);
|
||||
}
|
||||
|
||||
// 获取实例流程图
|
||||
export async function getInstanceDiagram(instanceId: number): Promise<string> {
|
||||
return request.get(`/api/v1/workflow/instances/${instanceId}/diagram`, {
|
||||
responseType: 'text'
|
||||
});
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
import { BaseResponse, BaseQuery } from '@/types/base';
|
||||
|
||||
// 工作流实例状态枚举
|
||||
export enum WorkflowInstanceStatus {
|
||||
RUNNING = 'RUNNING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
SUSPENDED = 'SUSPENDED',
|
||||
TERMINATED = 'TERMINATED'
|
||||
}
|
||||
|
||||
// 任务状态枚举
|
||||
export enum TaskStatus {
|
||||
PENDING = 'PENDING',
|
||||
RUNNING = 'RUNNING',
|
||||
COMPLETED = 'COMPLETED',
|
||||
FAILED = 'FAILED',
|
||||
SKIPPED = 'SKIPPED'
|
||||
}
|
||||
|
||||
// 工作流实例
|
||||
export interface WorkflowInstance extends BaseResponse {
|
||||
id: number;
|
||||
workflowKey: string;
|
||||
workflowName: string;
|
||||
workflowVersion: number;
|
||||
businessKey?: string;
|
||||
status: WorkflowInstanceStatus;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
startUserId?: number;
|
||||
startUserName?: string;
|
||||
currentTasks: TaskInstance[];
|
||||
variables: Record<string, any>;
|
||||
processDefinitionId: string;
|
||||
}
|
||||
|
||||
// 任务实例
|
||||
export interface TaskInstance extends BaseResponse {
|
||||
id: number;
|
||||
taskName: string;
|
||||
taskType: string;
|
||||
assignee?: string;
|
||||
assigneeName?: string;
|
||||
status: TaskStatus;
|
||||
startTime: string;
|
||||
endTime?: string;
|
||||
duration?: number;
|
||||
workflowInstanceId: number;
|
||||
nodeId: string;
|
||||
description?: string;
|
||||
formKey?: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// 工作流实例查询条件
|
||||
export interface WorkflowInstanceQuery extends BaseQuery {
|
||||
workflowKey?: string;
|
||||
workflowName?: string;
|
||||
status?: WorkflowInstanceStatus;
|
||||
businessKey?: string;
|
||||
startUserId?: number;
|
||||
startTimeFrom?: string;
|
||||
startTimeTo?: string;
|
||||
}
|
||||
|
||||
// 任务查询条件
|
||||
export interface TaskQuery extends BaseQuery {
|
||||
taskName?: string;
|
||||
assignee?: string;
|
||||
status?: TaskStatus;
|
||||
workflowInstanceId?: number;
|
||||
taskType?: string;
|
||||
}
|
||||
|
||||
// 实例统计信息
|
||||
export interface InstanceStatistics {
|
||||
total: number;
|
||||
running: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
suspended: number;
|
||||
terminated: number;
|
||||
avgDuration: number;
|
||||
todayStarted: number;
|
||||
todayCompleted: number;
|
||||
}
|
||||
@ -1,298 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Card,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Progress,
|
||||
Table,
|
||||
Tag,
|
||||
Button,
|
||||
Space,
|
||||
message
|
||||
} from 'antd';
|
||||
import {
|
||||
SyncOutlined,
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
PauseCircleOutlined,
|
||||
BarChartOutlined,
|
||||
LineChartOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import * as instanceService from '../Instance/service';
|
||||
import type {
|
||||
InstanceStatistics,
|
||||
WorkflowInstance,
|
||||
WorkflowInstanceStatus
|
||||
} from '../Instance/types';
|
||||
|
||||
const WorkflowMonitor: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [statistics, setStatistics] = useState<InstanceStatistics | null>(null);
|
||||
const [recentInstances, setRecentInstances] = useState<WorkflowInstance[]>([]);
|
||||
|
||||
// 加载监控数据
|
||||
const loadMonitorData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [statsData, instancesData] = await Promise.all([
|
||||
instanceService.getInstanceStatistics(),
|
||||
instanceService.getInstances({ pageNum: 0, pageSize: 10 })
|
||||
]);
|
||||
setStatistics(statsData);
|
||||
setRecentInstances(instancesData.content);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
message.error(error.message);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadMonitorData();
|
||||
// 定时刷新数据
|
||||
const interval = setInterval(loadMonitorData, 30000); // 30秒刷新一次
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// 状态渲染
|
||||
const renderStatus = (status: WorkflowInstanceStatus) => {
|
||||
const statusConfig = {
|
||||
RUNNING: { color: 'processing', text: '运行中', icon: <SyncOutlined spin /> },
|
||||
COMPLETED: { color: 'success', text: '已完成', icon: <CheckCircleOutlined /> },
|
||||
FAILED: { color: 'error', text: '失败', icon: <CloseCircleOutlined /> },
|
||||
SUSPENDED: { color: 'warning', text: '已挂起', icon: <PauseCircleOutlined /> },
|
||||
TERMINATED: { color: 'default', text: '已终止', icon: <CloseCircleOutlined /> }
|
||||
};
|
||||
|
||||
const config = statusConfig[status] || { color: 'default', text: status, icon: null };
|
||||
return (
|
||||
<Tag color={config.color} icon={config.icon}>
|
||||
{config.text}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// 计算健康度
|
||||
const getHealthScore = () => {
|
||||
if (!statistics || statistics.total === 0) return 0;
|
||||
const successRate = (statistics.completed / statistics.total) * 100;
|
||||
return Math.round(successRate);
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '实例ID',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
width: 80,
|
||||
},
|
||||
{
|
||||
title: '工作流名称',
|
||||
dataIndex: 'workflowName',
|
||||
key: 'workflowName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: renderStatus,
|
||||
},
|
||||
{
|
||||
title: '开始时间',
|
||||
dataIndex: 'startTime',
|
||||
key: 'startTime',
|
||||
render: (time: string) => dayjs(time).format('MM-DD HH:mm'),
|
||||
},
|
||||
{
|
||||
title: '耗时',
|
||||
dataIndex: 'duration',
|
||||
key: 'duration',
|
||||
render: (duration: number) => {
|
||||
if (!duration) return '-';
|
||||
return `${Math.round(duration / 1000)}s`;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 顶部操作栏 */}
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Space>
|
||||
<h2 style={{ margin: 0 }}>工作流监控中心</h2>
|
||||
<Tag color="blue">实时监控</Tag>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={loadMonitorData}
|
||||
loading={loading}
|
||||
>
|
||||
刷新数据
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="总实例数"
|
||||
value={statistics?.total || 0}
|
||||
prefix={<BarChartOutlined />}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日启动"
|
||||
value={statistics?.todayStarted || 0}
|
||||
prefix={<LineChartOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日完成"
|
||||
value={statistics?.todayCompleted || 0}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
loading={loading}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', color: '#8c8c8c', marginBottom: '8px' }}>
|
||||
系统健康度
|
||||
</div>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={getHealthScore()}
|
||||
size={80}
|
||||
strokeColor={{
|
||||
'0%': '#108ee9',
|
||||
'100%': '#87d068',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 图表和列表 */}
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Card title="实例运行状态" loading={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="运行中"
|
||||
value={statistics?.running || 0}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
prefix={<SyncOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="已完成"
|
||||
value={statistics?.completed || 0}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
prefix={<CheckCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={{ marginTop: 24 }}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="失败"
|
||||
value={statistics?.failed || 0}
|
||||
valueStyle={{ color: '#ff4d4f' }}
|
||||
prefix={<CloseCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="已挂起"
|
||||
value={statistics?.suspended || 0}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
prefix={<PauseCircleOutlined />}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Card title="性能指标" loading={loading}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '16px', fontWeight: 'bold', color: '#1890ff' }}>
|
||||
平均执行时间
|
||||
</div>
|
||||
<div style={{ fontSize: '24px', color: '#fa8c16', marginTop: '8px' }}>
|
||||
{Math.round((statistics?.avgDuration || 0) / 1000)}秒
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px' }}>成功率</div>
|
||||
<div style={{ fontSize: '20px', color: '#52c41a' }}>
|
||||
{getHealthScore()}%
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px' }}>活跃实例</div>
|
||||
<div style={{ fontSize: '20px', color: '#1890ff' }}>
|
||||
{(statistics?.running || 0) + (statistics?.suspended || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 最近实例 */}
|
||||
<Card
|
||||
title="最近实例"
|
||||
style={{ marginTop: 16 }}
|
||||
loading={loading}
|
||||
>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={recentInstances}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowMonitor;
|
||||
@ -45,11 +45,7 @@ const JenkinsManagerList = lazy(() => import('../pages/Deploy/JenkinsManager/Lis
|
||||
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
||||
const External = lazy(() => import('../pages/Deploy/External'));
|
||||
|
||||
// Workflow2 React Flow 版本
|
||||
const Workflow2DefinitionList = lazy(() => import('../pages/Workflow2/Definition'));
|
||||
const Workflow2Design = lazy(() => import('../pages/Workflow2/Design'));
|
||||
const Workflow2InstanceList = lazy(() => import('../pages/Workflow2/Instance'));
|
||||
const Workflow2Monitor = lazy(() => import('../pages/Workflow2/Monitor'));
|
||||
// Workflow2 相关路由已迁移到 Workflow,删除旧路由
|
||||
|
||||
// 创建路由
|
||||
const router = createBrowserRouter([
|
||||
@ -230,43 +226,6 @@ const router = createBrowserRouter([
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'workflow2',
|
||||
children: [
|
||||
{
|
||||
path: 'definition',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent/>}>
|
||||
<Workflow2DefinitionList/>
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'design/:id',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent/>}>
|
||||
<Workflow2Design/>
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'instance',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent/>}>
|
||||
<Workflow2InstanceList/>
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
{
|
||||
path: 'monitor',
|
||||
element: (
|
||||
<Suspense fallback={<LoadingComponent/>}>
|
||||
<Workflow2Monitor/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '*',
|
||||
element: <Navigate to="/dashboard"/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user