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

View File

@ -1,36 +1,35 @@
import React, {useEffect, useState} from 'react';
import {Card, Empty, Spin, Tabs, Tooltip} from 'antd';
import {NodeCategory, NodeType} from '../../types';
import {getNodeTypes} from '../../service';
import * as Icons from '@ant-design/icons';
import styles from './NodePanel.module.less';
import React, { useEffect, useState } from 'react';
import { Tabs, Spin } from 'antd';
import { NodeType, getNodeTypes } from '../../service';
import './index.css';
interface NodePanelProps {
onNodeDragStart: (nodeType: NodeType) => void;
}
const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => {
const [loading, setLoading] = useState(false);
const NodePanel: React.FC<NodePanelProps> = ({ onNodeDragStart }) => {
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
const [loading, setLoading] = useState(false);
// 获取节点类型列表
useEffect(() => {
const fetchNodeTypes = async () => {
setLoading(true);
try {
const response = await getNodeTypes({enabled: true});
const response = await getNodeTypes({ enabled: true });
if (response) {
setNodeTypes(response);
}
} catch (error) {
console.error('获取节点类型失败:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchNodeTypes();
}, []);
// 按类分组节点类型
// 按分组节点类型
const groupedNodeTypes = nodeTypes.reduce((acc, nodeType) => {
const category = nodeType.category;
if (!acc[category]) {
@ -38,84 +37,62 @@ const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => {
}
acc[category].push(nodeType);
return acc;
}, {} as Record<NodeCategory, NodeType[]>);
}, {} as Record<string, NodeType[]>);
// 渲染图标
const renderIcon = (iconName: string) => {
// @ts-ignore
const Icon = Icons[iconName];
return Icon ? <Icon/> : null;
};
// 渲染节点类型卡片
const renderNodeTypeCard = (nodeType: NodeType) => (
<Tooltip
// 将分组转换为 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}
title={nodeType.description}
placement="right"
mouseEnterDelay={0.5}
>
<Card
className={styles.nodeCard}
className="node-card"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('nodeType', JSON.stringify(nodeType));
e.dataTransfer.setData('node-type', JSON.stringify(nodeType));
onNodeDragStart(nodeType);
}}
style={{ borderColor: nodeType.color }}
>
<div className={styles.nodeIcon} style={{backgroundColor: nodeType.color}}>
{renderIcon(nodeType.icon)}
<div className="node-icon" style={{ color: nodeType.color }}>
<i className={nodeType.icon} />
</div>
<div className="node-name">{nodeType.name}</div>
</div>
))}
</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 className="node-panel-loading">
<Spin />
</div>
);
}
return (
<div className="node-panel">
<Tabs
defaultActiveKey={NodeCategory.TASK}
className="node-tabs"
defaultActiveKey="TASK"
items={tabItems}
className={styles.nodeTabs}
/>
</div>
);
};
// 获取类别标签
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 {
height: calc(100vh - 180px);
border: 1px solid #f0f0f0;
:global {
.workflow-designer {
&-container {
height: calc(100vh - 170px);
background: #fff;
border-radius: 2px;
background: #fff;
.sider {
border-right: 1px solid #f0f0f0;
background: #fff;
.nodePanel {
padding: 16px;
height: 100%;
overflow-y: auto;
}
}
.content {
&-sider {
background: #fff;
border-right: 1px solid #e8e8e8;
}
&-content {
position: relative;
background: #fafafa;
padding: 16px;
}
.graph {
position: absolute;
top: 0;
right: 0;
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 {
export interface IIndexModuleLess {
declare namespace DesignerModuleLessNamespace {
export interface IDesignerModuleLess {
container: string;
sider: string;
nodePanel: string;
content: string;
graph: string;
}
}
declare module '*.less' {
const content: IndexModuleLessNamespace.IIndexModuleLess;
const content: DesignerModuleLessNamespace.IDesignerModuleLess;
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 {ArrowLeftOutlined, SaveOutlined} from '@ant-design/icons';
import {getDefinition, updateDefinition} from '../../service';
import {WorkflowDefinition, WorkflowStatus, NodeType} from '../../types';
import {NodeType, WorkflowDefinition, WorkflowStatus} from '../../../Workflow/types';
import {Graph} from '@antv/x6';
import '@antv/x6-react-shape';
import styles from './index.module.less';
import NodePanel from './NodePanel';
import './index.module.less';
import NodePanel from './components/NodePanel';
const {Sider, Content} = Layout;
@ -220,12 +220,12 @@ const FlowDesigner: React.FC = () => {
</Space>
}
>
<Layout className={styles.container}>
<Sider width={280} className={styles.sider}>
<Layout className="workflow-designer-container">
<Sider width={280} className="workflow-designer-sider">
<NodePanel onNodeDragStart={handleNodeDragStart}/>
</Sider>
<Content className={styles.content}>
<div ref={containerRef} className={styles.graph}/>
<Content className="workflow-designer-content">
<div ref={containerRef} className="workflow-designer-graph"/>
</Content>
</Layout>
</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
} from '../service';
import {
CreateWorkflowDefinitionRequest,
WorkflowDefinition,
WorkflowStatus,
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": {
"target": "ES2020",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"typeRoots": ["./node_modules/@types", "./src/types"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["@antv/x6"],
"allowSyntheticDefaultImports": true
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]