1
This commit is contained in:
parent
e2f176503d
commit
0713c2c83e
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,17 @@
|
||||
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 { 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 [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 获取节点类型列表
|
||||
useEffect(() => {
|
||||
const fetchNodeTypes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -21,16 +19,17 @@ const NodePanel: React.FC<NodePanelProps> = ({onNodeDragStart}) => {
|
||||
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'}}>
|
||||
<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;
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
|
||||
31
frontend/src/pages/Workflow/Definition/Designer/service.ts
Normal file
31
frontend/src/pages/Workflow/Definition/Designer/service.ts
Normal 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 });
|
||||
17
frontend/src/pages/Workflow/Definition/constants.ts
Normal file
17
frontend/src/pages/Workflow/Definition/constants.ts
Normal 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;
|
||||
@ -10,7 +10,6 @@ import {
|
||||
publishDefinition
|
||||
} from '../service';
|
||||
import {
|
||||
CreateWorkflowDefinitionRequest,
|
||||
WorkflowDefinition,
|
||||
WorkflowStatus,
|
||||
WorkflowDefinitionBase
|
||||
|
||||
129
frontend/src/pages/Workflow/Definition/service.ts
Normal file
129
frontend/src/pages/Workflow/Definition/service.ts
Normal 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;
|
||||
};
|
||||
15
frontend/src/pages/Workflow/Definition/types.ts
Normal file
15
frontend/src/pages/Workflow/Definition/types.ts
Normal 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
4
frontend/src/types/less.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
declare module '*.less' {
|
||||
const classes: { readonly [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
@ -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" }]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user