feat(web): Add Workflow

This commit is contained in:
zhaoying
2025-12-22 10:46:19 +08:00
parent e1bccff79b
commit 281aec23e3
65 changed files with 2843 additions and 31 deletions

View File

@@ -0,0 +1,19 @@
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
const AddNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}
return (
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-30 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
})}>
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
{data.icon} {data.label}
</span>
</div>
);
};
export default AddNode;

View File

@@ -0,0 +1,155 @@
import React from 'react';
import clsx from 'clsx';
import { Button } from 'antd'
import type { ReactShapeConfig } from '@antv/x6-react-shape';
const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {};
const addPort = (e: React.MouseEvent) => {
if (!node || !node.addPort) return;
e.stopPropagation();
const currentPorts = node.getPorts();
const totalPorts = currentPorts.length;
// 如果没有端口添加第一个端口和ELSE端口
if (totalPorts === 0) {
// 添加第一个ELIF端口
node.addPort({
id: 'elif_1',
group: 'right',
attrs: {
text: {
text: 'ELIF 1',
},
},
});
// 添加ELSE端口
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
return;
}
// 如果只有一个端口确保它是ELSE然后在之前添加ELIF
if (totalPorts === 1) {
const existingPort = currentPorts[0];
// 如果现有端口不是ELSE先移除它
if (node.removePort && existingPort.id !== 'else') {
node.removePort(existingPort.id as string);
// 添加ELIF端口
node.addPort({
id: 'elif_1',
group: 'right',
attrs: {
text: {
text: 'ELIF 1',
},
},
});
}
// 添加或确保存在ELSE端口
if (existingPort.id !== 'else') {
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
}
return;
}
// 获取最后一个端口确保它是ELSE
let lastPort = currentPorts[totalPorts - 1];
// 如果最后一个端口不是ELSE先移除它
if (node.removePort && lastPort.id !== 'else') {
node.removePort(lastPort.id as string);
// 添加ELSE端口作为最后一个
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
// 更新currentPorts和totalPorts
const updatedPorts = node.getPorts();
const updatedTotal = updatedPorts.length;
lastPort = updatedPorts[updatedTotal - 1];
}
// 计算新的ELIF端口数量最后一个是ELSE不算在内
const elifCount = totalPorts - 1;
const newElifCount = elifCount + 1;
// 如果有removePort方法先移除最后一个端口(ELSE)添加新的ELIF端口再添加回ELSE端口
if (node.removePort) {
// 移除最后一个端口(ELSE)
node.removePort(lastPort.id as string);
// 添加新的ELIF端口在倒数第二个位置
node.addPort({
id: `elif_${newElifCount}`,
group: 'right',
attrs: {
text: {
text: `ELIF ${newElifCount}`,
},
},
});
// 添加回ELSE端口
node.addPort({
id: 'else',
group: 'right',
attrs: {
text: {
text: 'ELSE',
},
},
});
}
};
// const removeElif = (e: React.MouseEvent) => {
// e.stopPropagation();
// };
return (
<div className={clsx(`rb:border rb:rounded-[12px] rb:relative rb:min-w-[200px] rb:min-h-[120px] rb:p-2`, {
'rb:border-orange-500 rb:border-[3px] rb:bg-orange-50 rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-[#FFFFFF] rb:text-[#374151]': !data.isSelected
})}>
<Button onClick={addPort}>+ ELIF</Button>
{/* 标题区域 */}
<div className="rb:absolute rb:-top-3 rb:left-2 rb:bg-blue-500 rb:rounded-2xl rb:px-3 rb:py-1 rb:flex rb:items-center rb:gap-1.5 rb:text-white rb:text-xs rb:font-bold rb:z-10">
<div className="rb:w-4 rb:h-4 rb:bg-white rb:rounded rb:flex rb:items-center rb:justify-center rb:text-blue-500 rb:text-[10px]">
🔀
</div>
</div>
</div>
);
};
export default ConditionNode;

View File

@@ -0,0 +1,19 @@
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
const GroupStartNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}
return (
<div className={clsx('rb:group rb:relative rb:h-10 rb:w-20 rb:border rb:rounded-xl rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:p-1 rb:box-border', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data.isSelected
})}>
<span className="rb:overflow-hidden rb:whitespace-nowrap rb:text-ellipsis">
{data.icon} {data.label}
</span>
</div>
);
};
export default GroupStartNode;

View File

@@ -0,0 +1,98 @@
import { useEffect } from 'react';
import clsx from 'clsx';
import { Dropdown } from 'antd';
import { SmallDashOutlined } from '@ant-design/icons';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { graphNodeLibrary } from '../../constant';
interface NodeData {
isSelected?: boolean;
type?: string;
label?: string;
icon?: string;
parentId?: string;
isGroup?: boolean;
}
const IterationNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const data = node.getData() as NodeData;
useEffect(() => {
initNodes()
}, [])
const initNodes = () => {
// 添加默认子节点
const parentBBox = node.getBBox();
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
const centerY = parentBBox.y + 50; // 默认节点高度的一半
const childNode1 = graph.addNode({
...graphNodeLibrary.groupStart,
x: centerX,
y: centerY,
data: {
type: 'default',
label: '开始',
// icon: '📌',
parentId: node.id,
isDefault: true // 标记为默认节点,不可删除
},
});
const childNode2 = graph.addNode({
...graphNodeLibrary.addStart,
x: centerX + 150,
y: centerY,
data: {
type: 'default',
label: '添加节点',
icon: '+',
parentId: node.id,
},
});
node.addChild(childNode1)
node.addChild(childNode2)
}
return (
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-xl rb:relative rb:min-w-75 rb:min-h-50 rb:p-4', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
})}>
{/* 标题区域 */}
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
🔁
</div>
</div>
<Dropdown
menu={{items: [
{
key: '1',
label: '删除',
},
{
key: '2',
label: '复制',
},
{
key: '3',
label: '删除',
}
]}}
>
<SmallDashOutlined
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
'rb:visible': data.isSelected
})}
/>
</Dropdown>
{/* 画布内容区域 */}
<div className="rb:mt-6 rb:min-h-37.5 rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
</div>
);
};
export default IterationNode;

View File

@@ -0,0 +1,98 @@
import { useEffect } from 'react';
import clsx from 'clsx';
import { Dropdown } from 'antd';
import { SmallDashOutlined } from '@ant-design/icons';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { graphNodeLibrary } from '../../constant';
interface NodeData {
isSelected?: boolean;
type?: string;
label?: string;
icon?: string;
parentId?: string;
isGroup?: boolean;
}
const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const data = node.getData() as NodeData;
useEffect(() => {
initNodes()
}, [])
const initNodes = () => {
// 添加默认子节点
const parentBBox = node.getBBox();
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
const centerY = parentBBox.y + 50; // 默认节点高度的一半
const childNode1 = graph.addNode({
...graphNodeLibrary.groupStart,
x: centerX,
y: centerY,
data: {
type: 'default',
label: '开始',
// icon: '📌',
parentId: node.id,
isDefault: true // 标记为默认节点,不可删除
},
});
const childNode2 = graph.addNode({
...graphNodeLibrary.addStart,
x: centerX + 150,
y: centerY,
data: {
type: 'default',
label: '添加节点',
icon: '+',
parentId: node.id,
},
});
node.addChild(childNode1)
node.addChild(childNode2)
}
return (
<div className={clsx('rb:group rb:border-2 rb:border-dashed rb:rounded-[12px] rb:relative rb:min-w-[300px] rb:min-h-[200px] rb:p-4', {
'rb:border-orange-500 rb:border-[3px] rb:bg-white rb:text-gray-700': data?.isSelected,
'rb:border-[#d1d5db] rb:bg-white rb:text-[#374151]': !data?.isSelected
})}>
{/* 标题区域 */}
<div className="rb:absolute rb:-top-3 rb:left-4 rb:bg-[#10b981] rb:rounded-[20px] rb:p-[8px_16px] rb:flex rb:items-center rb:gap-2 rb:text-white rb:text-[14px] rb:font-bold rb:z-10">
<div className="rb:w-5 rb:h-5 rb:bg-[#FFFFFF] rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:text-[12px] rb:text-[#10b981]">
</div>
</div>
<Dropdown
menu={{items: [
{
key: '1',
label: '删除',
},
{
key: '2',
label: '复制',
},
{
key: '3',
label: '删除',
}
]}}
>
<SmallDashOutlined
className={clsx("rb:cursor-pointer rb:right-1 rb:top-1 rb:invisible rb:absolute rb:group-hover:visible", {
'rb:visible': data.isSelected
})}
/>
</Dropdown>
{/* 画布内容区域 */}
<div className="rb:mt-6 rb:min-h-[150px] rb:w-full rb:bg-[radial-gradient(circle,#e5e7eb_1px,transparent_1px)] rb:bg-size-[12px_12px]"></div>
</div>
);
};
export default LoopNode;

View File

@@ -0,0 +1,31 @@
import clsx from 'clsx';
import { useTranslation } from 'react-i18next'
import type { ReactShapeConfig } from '@antv/x6-react-shape';
const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
const data = node?.getData() || {}
const { t } = useTranslation()
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-16 rb:w-60 rb:p-2.5 rb:border rb:rounded-xl rb:bg-white rb:hover:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.12)]', {
'rb:border-[#155EEF]': data.isSelected,
'rb:border-[#DFE4ED]': !data.isSelected
})}>
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
<img src={data.icon} className="rb:w-5 rb:h-5" />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
</div>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => {}}
></div>
</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:mt-1.5">{t('workflow.clickToConfigure')}</div>
</div>
);
};
export default NormalNode;