This commit is contained in:
戚辰先生 2024-12-05 21:19:23 +08:00
parent e2f176503d
commit 0713c2c83e
13 changed files with 412 additions and 181 deletions

View File

@ -0,0 +1,67 @@
.node-panel {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
}
.node-panel-loading {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.node-panel-content {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 12px;
padding: 12px;
overflow-y: auto;
}
.node-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 12px;
border: 1px solid #e8e8e8;
border-radius: 4px;
cursor: move;
transition: all 0.3s;
background: white;
}
.node-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.node-icon {
font-size: 24px;
margin-bottom: 8px;
}
.node-name {
font-size: 12px;
text-align: center;
color: #333;
line-height: 1.5;
}
.node-tabs {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.node-tabs :global(.ant-tabs-content) {
flex: 1;
overflow: hidden;
}
.node-tabs :global(.ant-tabs-tabpane) {
height: 100%;
overflow: hidden;
}

View File

@ -1,7 +1,7 @@
.nodeTabs { :global {
height: 100%; .workflow-node-tabs {
height: 100%;
:global {
.ant-tabs { .ant-tabs {
height: 100%; height: 100%;
@ -68,24 +68,24 @@
color: #999; color: #999;
} }
} }
}
.nodeCard { .workflow-node-card {
margin-bottom: 16px; margin-bottom: 16px;
cursor: move; cursor: move;
border-radius: 4px; border-radius: 4px;
transition: all 0.3s; transition: all 0.3s;
background: #fff; background: #fff;
border: 1px solid #f0f0f0; border: 1px solid #f0f0f0;
padding: 12px; padding: 12px;
&:hover { &:hover {
border-color: #1890ff; border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15); box-shadow: 0 2px 8px rgba(24, 144, 255, 0.15);
transform: translateY(-1px); transform: translateY(-1px);
}
} }
.nodeIcon { .workflow-node-icon {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 8px; border-radius: 8px;
@ -100,17 +100,17 @@
.anticon { .anticon {
transition: all 0.3s; transition: all 0.3s;
} }
}
&:hover .nodeIcon { &:hover {
transform: scale(1.1);
.anticon {
transform: scale(1.1); transform: scale(1.1);
.anticon {
transform: scale(1.1);
}
} }
} }
.nodeName { .workflow-node-name {
font-size: 14px; font-size: 14px;
color: #333; color: #333;
text-align: center; text-align: center;
@ -118,8 +118,4 @@
margin: 0; margin: 0;
font-weight: 500; font-weight: 500;
} }
&:active {
transform: scale(0.98);
}
} }

View File

@ -1,121 +1,98 @@
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from 'react';
import {Card, Empty, Spin, Tabs, Tooltip} from 'antd'; import { Tabs, Spin } from 'antd';
import {NodeCategory, NodeType} from '../../types'; import { NodeType, getNodeTypes } from '../../service';
import {getNodeTypes} from '../../service'; import './index.css';
import * as Icons from '@ant-design/icons';
import styles from './NodePanel.module.less';
interface NodePanelProps { interface NodePanelProps {
onNodeDragStart: (nodeType: NodeType) => void; onNodeDragStart: (nodeType: NodeType) => void;
} }
const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => { const NodePanel: React.FC<NodePanelProps> = ({ onNodeDragStart }) => {
const [loading, setLoading] = useState(false); const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]); const [loading, setLoading] = useState(false);
// 获取节点类型列表 useEffect(() => {
const fetchNodeTypes = async () => { const fetchNodeTypes = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await getNodeTypes({enabled: true}); const response = await getNodeTypes({ enabled: true });
if (response) { if (response) {
setNodeTypes(response); setNodeTypes(response);
}
} finally {
setLoading(false);
} }
} catch (error) {
console.error('获取节点类型失败:', error);
} finally {
setLoading(false);
}
}; };
useEffect(() => { fetchNodeTypes();
fetchNodeTypes(); }, []);
}, []);
// 按分类分组节点类型 // 按类别分组节点类型
const groupedNodeTypes = nodeTypes.reduce((acc, nodeType) => { const groupedNodeTypes = nodeTypes.reduce((acc, nodeType) => {
const category = nodeType.category; const category = nodeType.category;
if (!acc[category]) { if (!acc[category]) {
acc[category] = []; acc[category] = [];
}
acc[category].push(nodeType);
return acc;
}, {} as Record<NodeCategory, NodeType[]>);
// 渲染图标
const renderIcon = (iconName: string) => {
// @ts-ignore
const Icon = Icons[iconName];
return Icon ? <Icon/> : null;
};
// 渲染节点类型卡片
const renderNodeTypeCard = (nodeType: NodeType) => (
<Tooltip
key={nodeType.code}
title={nodeType.description}
placement="right"
mouseEnterDelay={0.5}
>
<Card
className={styles.nodeCard}
draggable
onDragStart={(e) => {
e.dataTransfer.setData('nodeType', JSON.stringify(nodeType));
onNodeDragStart(nodeType);
}}
>
<div className={styles.nodeIcon} style={{backgroundColor: nodeType.color}}>
{renderIcon(nodeType.icon)}
</div>
<div className={styles.nodeName}>{nodeType.name}</div>
</Card>
</Tooltip>
);
const categoryIcons = {
[NodeCategory.TASK]: renderIcon('CodeOutlined'),
[NodeCategory.EVENT]: renderIcon('ThunderboltOutlined'),
[NodeCategory.GATEWAY]: renderIcon('ForkOutlined')
};
const categoryLabels = {
[NodeCategory.TASK]: '任务节点',
[NodeCategory.EVENT]: '事件节点',
[NodeCategory.GATEWAY]: '网关节点'
};
const tabItems = Object.values(NodeCategory).map(category => ({
key: category,
label: (
<span>
{categoryIcons[category]} {categoryLabels[category]}
<span style={{color: '#999', fontSize: 12, marginLeft: 4}}>
({groupedNodeTypes[category]?.length || 0})
</span>
</span>
),
children: groupedNodeTypes[category]?.map(renderNodeTypeCard) || (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={`暂无${categoryLabels[category]}`}
/>
)
}));
if (loading) {
return (
<div style={{textAlign: 'center', padding: '20px 0'}}>
<Spin/>
</div>
);
} }
acc[category].push(nodeType);
return acc;
}, {} as Record<string, NodeType[]>);
// 将分组转换为 Tabs 项
const tabItems = Object.entries(groupedNodeTypes).map(([category, types]) => ({
key: category,
label: getCategoryLabel(category),
children: (
<div className="node-panel-content">
{types.map((nodeType) => (
<div
key={nodeType.code}
className="node-card"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('node-type', JSON.stringify(nodeType));
onNodeDragStart(nodeType);
}}
style={{ borderColor: nodeType.color }}
>
<div className="node-icon" style={{ color: nodeType.color }}>
<i className={nodeType.icon} />
</div>
<div className="node-name">{nodeType.name}</div>
</div>
))}
</div>
),
}));
if (loading) {
return ( return (
<Tabs <div className="node-panel-loading">
defaultActiveKey={NodeCategory.TASK} <Spin />
items={tabItems} </div>
className={styles.nodeTabs}
/>
); );
}
return (
<div className="node-panel">
<Tabs
className="node-tabs"
defaultActiveKey="TASK"
items={tabItems}
/>
</div>
);
}; };
export default NodePanel; // 获取类别标签
const getCategoryLabel = (category: string) => {
const labels: Record<string, string> = {
TASK: '任务节点',
EVENT: '事件节点',
GATEWAY: '网关节点',
};
return labels[category] || category;
};
export default NodePanel;

View File

@ -1,30 +1,28 @@
.container { :global {
height: calc(100vh - 180px); .workflow-designer {
border: 1px solid #f0f0f0; &-container {
border-radius: 2px; height: calc(100vh - 170px);
background: #fff; background: #fff;
border-radius: 2px;
.sider {
border-right: 1px solid #f0f0f0;
background: #fff;
.nodePanel {
padding: 16px;
height: 100%;
overflow-y: auto;
} }
}
.content { &-sider {
position: relative; background: #fff;
background: #fafafa; border-right: 1px solid #e8e8e8;
}
.graph { &-content {
position: absolute; position: relative;
top: 0; background: #fafafa;
right: 0; padding: 16px;
bottom: 0; }
left: 0;
&-graph {
width: 100%;
height: 100%;
background: #fff;
border: 1px solid #e8e8e8;
border-radius: 2px;
} }
} }
} }

View File

@ -1,14 +1,13 @@
declare namespace IndexModuleLessNamespace { declare namespace DesignerModuleLessNamespace {
export interface IIndexModuleLess { export interface IDesignerModuleLess {
container: string; container: string;
sider: string; sider: string;
nodePanel: string;
content: string; content: string;
graph: string; graph: string;
} }
} }
declare module '*.less' { declare module '*.less' {
const content: IndexModuleLessNamespace.IIndexModuleLess; const content: DesignerModuleLessNamespace.IDesignerModuleLess;
export default content; export default content;
} }

View File

@ -3,11 +3,11 @@ import {useNavigate, useParams} from 'react-router-dom';
import {Button, Card, Layout, message, Space, Spin} from 'antd'; import {Button, Card, Layout, message, Space, Spin} from 'antd';
import {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons'; import {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons';
import {getDefinition, updateDefinition} from '../../service'; import {getDefinition, updateDefinition} from '../../service';
import {WorkflowDefinition, WorkflowStatus, NodeType} from '../../types'; import {NodeType, WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types';
import {Graph} from '@antv/x6'; import {Graph} from '@antv/x6';
import '@antv/x6-react-shape'; import '@antv/x6-react-shape';
import styles from './index.module.less'; import './index.module.less';
import NodePanel from './NodePanel'; import NodePanel from './components/NodePanel';
const {Sider, Content} = Layout; const {Sider, Content} = Layout;
@ -220,12 +220,12 @@ const FlowDesigner: React.FC = () => {
</Space> </Space>
} }
> >
<Layout className={styles.container}> <Layout className="workflow-designer-container">
<Sider width={280} className={styles.sider}> <Sider width={280} className="workflow-designer-sider">
<NodePanel onNodeDragStart={handleNodeDragStart}/> <NodePanel onNodeDragStart={handleNodeDragStart}/>
</Sider> </Sider>
<Content className={styles.content}> <Content className="workflow-designer-content">
<div ref={containerRef} className={styles.graph}/> <div ref={containerRef} className="workflow-designer-graph"/>
</Content> </Content>
</Layout> </Layout>
</Card> </Card>

View File

@ -0,0 +1,31 @@
import request from '@/utils/request';
export interface NodeExecutor {
code: string;
name: string;
description: string;
configSchema: Record<string, any>;
}
export interface NodeType {
id: number;
code: string;
name: string;
category: 'TASK' | 'EVENT' | 'GATEWAY';
description: string;
enabled: boolean;
icon: string;
color: string;
executors: NodeExecutor[];
createTime: string;
updateTime: string;
}
export interface NodeTypeQuery {
enabled?: boolean;
category?: 'TASK' | 'EVENT' | 'GATEWAY';
}
// 获取节点类型列表
export const getNodeTypes = (params?: NodeTypeQuery) =>
request.get<NodeType[]>('/api/v1/node-types', { params });

View File

@ -0,0 +1,17 @@
import { NodeCategory } from './types';
// 节点分类配置
export const NODE_CATEGORY_CONFIG = {
[NodeCategory.TASK]: {
icon: 'CodeOutlined',
label: '任务节点',
},
[NodeCategory.EVENT]: {
icon: 'ThunderboltOutlined',
label: '事件节点',
},
[NodeCategory.GATEWAY]: {
icon: 'ForkOutlined',
label: '网关节点',
},
} as const;

View File

@ -10,7 +10,6 @@ import {
publishDefinition publishDefinition
} from '../service'; } from '../service';
import { import {
CreateWorkflowDefinitionRequest,
WorkflowDefinition, WorkflowDefinition,
WorkflowStatus, WorkflowStatus,
WorkflowDefinitionBase WorkflowDefinitionBase

View File

@ -0,0 +1,129 @@
import { NodeCategory, NodeType } from '../types';
// Mock 节点类型数据
const mockNodeTypes: NodeType[] = [
{
id: 1,
code: 'start',
name: '开始节点',
color: '#67C23A',
icon: 'PlayCircleOutlined',
category: NodeCategory.EVENT,
description: '流程的开始节点',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 2,
code: 'end',
name: '结束节点',
color: '#F56C6C',
icon: 'StopOutlined',
category: NodeCategory.EVENT,
description: '流程的结束节点',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 3,
code: 'approval',
name: '审批节点',
color: '#409EFF',
icon: 'AuditOutlined',
category: NodeCategory.TASK,
description: '人工审批节点',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 4,
code: 'script',
name: '脚本节点',
color: '#909399',
icon: 'CodeOutlined',
category: NodeCategory.TASK,
description: '执行自定义脚本',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 5,
code: 'webhook',
name: 'Webhook',
color: '#9C27B0',
icon: 'ApiOutlined',
category: NodeCategory.TASK,
description: '调用外部 Webhook',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 6,
code: 'exclusive',
name: '排他网关',
color: '#FF9800',
icon: 'ForkOutlined',
category: NodeCategory.GATEWAY,
description: '根据条件选择一条分支执行',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
},
{
id: 7,
code: 'parallel',
name: '并行网关',
color: '#795548',
icon: 'PartitionOutlined',
category: NodeCategory.GATEWAY,
description: '同时执行所有分支',
enabled: true,
executors: [],
configSchema: '{}',
defaultConfig: '{}',
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
}
];
interface GetNodeTypesParams {
enabled?: boolean;
}
// 获取节点类型列表
export const getNodeTypes = async (params?: GetNodeTypesParams): Promise<NodeType[]> => {
// 模拟 API 延迟
await new Promise(resolve => setTimeout(resolve, 500));
let result = [...mockNodeTypes];
// 根据启用状态筛选
if (params?.enabled !== undefined) {
result = result.filter(node => node.enabled === params.enabled);
}
return result;
};

View File

@ -0,0 +1,15 @@
export enum NodeCategory {
TASK = 'TASK',
EVENT = 'EVENT',
GATEWAY = 'GATEWAY'
}
export interface NodeType {
code: string;
name: string;
color: string;
icon: string;
category: NodeCategory;
description?: string;
enabled?: boolean;
}

4
frontend/src/types/less.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
declare module '*.less' {
const classes: { readonly [key: string]: string };
export default classes;
}

View File

@ -1,26 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext", "allowJs": false,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "esModuleInterop": false,
"allowImportingTsExtensions": true, "allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"strict": true, "typeRoots": ["./node_modules/@types", "./src/types"],
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"]
}, }
"types": ["@antv/x6"],
"allowSyntheticDefaultImports": true
}, },
"include": ["src"], "include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]