This commit is contained in:
dengqichen 2025-10-21 15:26:54 +08:00
parent c9b5ee9e95
commit 702c42823b
7 changed files with 162 additions and 547 deletions

View File

@ -96,22 +96,43 @@ const FlowCanvas: React.FC<FlowCanvasProps> = ({
} }
}, [onEdgesStateChange, onEdgesChange, edges]); }, [onEdgesStateChange, onEdgesChange, edges]);
// 连接验证 // 连接验证 - 利用 React Flow 的实时验证功能
const isValidConnection = useCallback((connection: Connection | Edge) => { const isValidConnection = useCallback((connection: Connection | Edge) => {
// 防止自连接 // 1. 防止自连接
if (connection.source === connection.target) { if (connection.source === connection.target) {
return false; return false;
} }
// 检查是否已存在连接 // 2. 检查是否已存在连接(防止重复)
const isDuplicate = edges.some( const isDuplicate = edges.some(
(edge) => (edge) =>
edge.source === connection.source && edge.source === connection.source &&
edge.target === connection.target edge.target === connection.target
); );
if (isDuplicate) {
return false;
}
return !isDuplicate; // 3. 获取源节点和目标节点
}, [edges]); const sourceNode = nodes.find(n => n.id === connection.source);
const targetNode = nodes.find(n => n.id === connection.target);
if (!sourceNode || !targetNode) {
return false;
}
// 4. 开始节点不能作为连接目标
if (targetNode.type === 'START_EVENT') {
return false;
}
// 5. 结束节点不能作为连接源
if (sourceNode.type === 'END_EVENT') {
return false;
}
return true;
}, [edges, nodes]);
// 处理拖拽放置 // 处理拖拽放置
const handleDrop = useCallback((event: React.DragEvent) => { const handleDrop = useCallback((event: React.DragEvent) => {

View File

@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { message } from 'antd'; import { message } from 'antd';
import * as definitionService from '../../Definition/service'; import * as definitionService from '../../Definition/service';
import type { FlowNode, FlowEdge } from '../types'; import type { FlowNode, FlowEdge } from '../types';
import { validateWorkflow } from '../utils/validator'; import { NodeType } from '../types';
interface WorkflowSaveData { interface WorkflowSaveData {
nodes: FlowNode[]; nodes: FlowNode[];
@ -13,6 +13,31 @@ interface WorkflowSaveData {
definitionData?: any; // 原始工作流定义数据 definitionData?: any; // 原始工作流定义数据
} }
/**
* -
* FlowCanvas isValidConnection
*/
const validateBeforeSave = (nodes: FlowNode[]): { valid: boolean; message?: string } => {
// 1. 检查是否为空
if (nodes.length === 0) {
return { valid: false, message: '流程图中没有任何节点,请至少添加一个节点' };
}
// 2. 检查必需的开始节点
const hasStartNode = nodes.some(node => node.data.nodeType === NodeType.START_EVENT);
if (!hasStartNode) {
return { valid: false, message: '流程图中必须包含开始节点' };
}
// 3. 检查必需的结束节点
const hasEndNode = nodes.some(node => node.data.nodeType === NodeType.END_EVENT);
if (!hasEndNode) {
return { valid: false, message: '流程图中必须包含结束节点' };
}
return { valid: true };
};
export const useWorkflowSave = () => { export const useWorkflowSave = () => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [lastSaved, setLastSaved] = useState<Date | null>(null); const [lastSaved, setLastSaved] = useState<Date | null>(null);
@ -20,8 +45,8 @@ export const useWorkflowSave = () => {
// 保存工作流数据 // 保存工作流数据
const saveWorkflow = useCallback(async (data: WorkflowSaveData): Promise<boolean> => { const saveWorkflow = useCallback(async (data: WorkflowSaveData): Promise<boolean> => {
// 保存前验证工作流 // 保存前验证(只验证业务规则,连接验证在 isValidConnection 中)
const validationResult = validateWorkflow(data.nodes, data.edges); const validationResult = validateBeforeSave(data.nodes);
if (!validationResult.valid) { if (!validationResult.valid) {
message.error(validationResult.message || '工作流验证失败'); message.error(validationResult.message || '工作流验证失败');
return false; return false;

View File

@ -13,19 +13,19 @@ export const EndEventNodeDefinition: BaseNodeDefinition = {
// 渲染配置(配置驱动) // 渲染配置(配置驱动)
renderConfig: { renderConfig: {
shape: 'circle', shape: 'rounded-rect',
size: { width: 100, height: 60 }, size: { width: 80, height: 48 },
icon: { icon: {
type: 'emoji', type: 'emoji',
content: '⏹️', content: '⏹️',
size: 40 size: 32
}, },
theme: { theme: {
primary: '#ef4444', primary: '#ef4444',
secondary: '#f87171', secondary: '#dc2626',
selectedBorder: '#3b82f6', selectedBorder: '#3b82f6',
hoverBorder: '#ef4444', hoverBorder: '#ef4444',
gradient: ['#fef2f2', '#fee2e2'] gradient: ['#ffffff', '#fef2f2']
}, },
handles: { handles: {
input: true, // 结束节点有输入 input: true, // 结束节点有输入

View File

@ -1,4 +1,4 @@
import { ConfigurableNodeDefinition, NodeType, NodeCategory } from './types'; import {ConfigurableNodeDefinition, NodeType, NodeCategory} from './types';
/** /**
* Jenkins构建节点定义 * Jenkins构建节点定义
@ -14,18 +14,18 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
// 渲染配置(配置驱动) // 渲染配置(配置驱动)
renderConfig: { renderConfig: {
shape: 'rounded-rect', shape: 'rounded-rect',
size: { width: 160, height: 80 }, size: {width: 140, height: 48},
icon: { icon: {
type: 'emoji', type: 'emoji',
content: '🔨', content: '🔨',
size: 40 size: 32
}, },
theme: { theme: {
primary: '#52c41a', primary: '#f59e0b',
secondary: '#73d13d', secondary: '#d97706',
selectedBorder: '#3b82f6', selectedBorder: '#3b82f6',
hoverBorder: '#73d13d', hoverBorder: '#f59e0b',
gradient: ['#ffffff', '#f6ffed'] gradient: ['#ffffff', '#fef3c7']
}, },
handles: { handles: {
input: true, input: true,
@ -67,104 +67,10 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
description: "Jenkins服务器的完整URL地址", description: "Jenkins服务器的完整URL地址",
default: "http://jenkins.example.com:8080", default: "http://jenkins.example.com:8080",
format: "uri" 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"] required: ["nodeName", "nodeCode", "jenkinsUrl"]
}, },
// 输入映射Schema
inputMappingSchema: {
type: "object",
title: "输入映射",
description: "从上游节点接收的数据映射配置",
properties: {
sourceCodeUrl: {
type: "string",
title: "源代码仓库地址",
description: "Git仓库的URL地址",
default: "${upstream.gitUrl}"
},
buildParameters: {
type: "object",
title: "构建参数",
description: "传递给Jenkins Job的构建参数",
default: {}
},
environmentVariables: {
type: "object",
title: "环境变量",
description: "构建时的环境变量",
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 // 输出映射Schema
outputMappingSchema: { outputMappingSchema: {
type: "object", type: "object",
@ -195,29 +101,6 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
title: "构建耗时", title: "构建耗时",
description: "构建耗时(毫秒)", description: "构建耗时(毫秒)",
default: 0 default: 0
},
artifactsUrl: {
type: "string",
title: "构建产物URL",
description: "构建产物的下载地址",
default: "${jenkins.artifactsUrl}"
},
consoleLog: {
type: "string",
title: "控制台日志",
description: "构建的控制台输出日志",
default: "${jenkins.consoleText}"
},
testResults: {
type: "object",
title: "测试结果",
description: "构建中的测试结果统计",
default: {
totalCount: 0,
passCount: 0,
failCount: 0,
skipCount: 0
}
} }
}, },
required: ["buildId", "buildStatus", "buildUrl"] required: ["buildId", "buildStatus", "buildUrl"]

View File

@ -13,19 +13,19 @@ export const StartEventNodeDefinition: BaseNodeDefinition = {
// 渲染配置(配置驱动) // 渲染配置(配置驱动)
renderConfig: { renderConfig: {
shape: 'circle', shape: 'rounded-rect',
size: { width: 100, height: 60 }, size: { width: 80, height: 48 },
icon: { icon: {
type: 'emoji', type: 'emoji',
content: '▶️', content: '▶️',
size: 40 size: 32
}, },
theme: { theme: {
primary: '#10b981', primary: '#10b981',
secondary: '#34d399', secondary: '#059669',
selectedBorder: '#3b82f6', selectedBorder: '#3b82f6',
hoverBorder: '#10b981', hoverBorder: '#10b981',
gradient: ['#ecfdf5', '#d1fae5'] gradient: ['#ffffff', '#ecfdf5']
}, },
handles: { handles: {
input: false, // 开始节点无输入 input: false, // 开始节点无输入

View File

@ -1,244 +1,137 @@
import React, { useState, CSSProperties } from 'react'; import React from 'react';
import { Handle, Position, NodeProps } from '@xyflow/react'; import { Handle, Position, NodeProps } from '@xyflow/react';
import type { FlowNodeData } from '../../types'; import type { FlowNodeData } from '../../types';
/** /**
* - * BaseNode - shadcn/ui
* renderConfig * React Flow + shadcn
*/ */
const BaseNode: React.FC<NodeProps> = ({ data, selected }) => { const BaseNode: React.FC<NodeProps> = ({ data, selected }) => {
const nodeData = data as FlowNodeData; const nodeData = data as FlowNodeData;
const definition = nodeData?.nodeDefinition; const definition = nodeData?.nodeDefinition;
// 如果没有 definition显示错误提示
if (!definition) { if (!definition) {
return <div></div>; return <div className="text-xs text-muted-foreground"></div>;
} }
const config = definition.renderConfig; const config = definition.renderConfig;
const [isHovered, setIsHovered] = useState(false);
// 🎨 计算容器样式 // shadcn 风格容器类名
const getContainerStyle = (): CSSProperties => { const getContainerClass = () => {
const { shape, size, theme } = config; // 基础样式 - shadcn card 风格
const baseClass = `
relative inline-flex items-center gap-2 px-3 py-2
bg-background border border-border
rounded-lg shadow-sm
transition-all duration-200
hover:shadow-md hover:border-primary/50
`;
const baseStyle: CSSProperties = { // 选中状态 - shadcn 的 ring 效果
minWidth: `${size.width}px`, const selectedClass = selected
minHeight: `${size.height}px`, ? 'border-primary shadow-md ring-2 ring-ring ring-offset-2 ring-offset-background'
display: 'flex', : '';
flexDirection: 'column',
alignItems: 'center', return `${baseClass} ${selectedClass}`;
justifyContent: 'center',
position: 'relative',
cursor: 'pointer',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}; };
// 根据形状设置样式 // 图标容器 - shadcn 风格
switch (shape) {
case 'circle':
baseStyle.borderRadius = '50%';
baseStyle.padding = '12px';
break;
case 'rounded-rect':
baseStyle.borderRadius = '12px';
baseStyle.padding = '16px 20px';
break;
case 'rect':
baseStyle.borderRadius = '4px';
baseStyle.padding = '16px 20px';
break;
case 'diamond':
baseStyle.transform = 'rotate(45deg)';
baseStyle.padding = '16px';
break;
}
// 边框和背景
const borderColor = selected
? theme.selectedBorder
: isHovered
? theme.hoverBorder
: '#e5e7eb';
baseStyle.border = `${selected ? '3px' : '2px'} solid ${borderColor}`;
baseStyle.background = selected
? `linear-gradient(135deg, ${theme.gradient[0]} 0%, #f0f9ff 100%)`
: `linear-gradient(135deg, ${theme.gradient[0]} 0%, ${theme.gradient[1]} 100%)`;
// 阴影
baseStyle.boxShadow = selected
? `0 8px 16px ${theme.selectedBorder}33, 0 2px 8px ${theme.selectedBorder}22`
: isHovered
? `0 6px 16px ${theme.hoverBorder}26, 0 2px 6px ${theme.hoverBorder}14`
: '0 2px 8px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)';
// Hover 效果
if (isHovered) {
baseStyle.transform = 'translateY(-2px)';
}
return baseStyle;
};
// 🔌 计算连接点样式
const getHandleStyle = (): CSSProperties => {
return {
background: config.theme.primary,
border: '3px solid white',
width: '14px',
height: '14px',
boxShadow: '0 2px 4px rgba(0,0,0,0.15)',
transition: 'all 0.2s ease',
transform: isHovered ? 'scale(1.2)' : 'scale(1)',
};
};
// 🎭 渲染图标
const renderIcon = () => { const renderIcon = () => {
const { icon, theme } = config; if (config.icon.type === 'emoji') {
if (icon.type === 'emoji') {
return ( return (
<div style={{ <div
width: `${icon.size || 40}px`, className="
height: `${icon.size || 40}px`, flex items-center justify-center
borderRadius: '10px', rounded-md
background: `linear-gradient(135deg, ${theme.primary} 0%, ${theme.secondary} 100%)`, transition-transform duration-200
display: 'flex', group-hover:scale-110
alignItems: 'center', "
justifyContent: 'center', style={{
boxShadow: `0 4px 8px ${theme.primary}33`, width: '32px',
transition: 'transform 0.2s ease', height: '32px',
transform: isHovered ? 'scale(1.1)' : 'scale(1)', background: `linear-gradient(135deg, ${config.theme.primary}, ${config.theme.secondary})`,
}}> }}
<span style={{ fontSize: `${(icon.size || 40) * 0.55}px` }}> >
{icon.content} <span
className="text-white text-base leading-none"
>
{config.icon.content}
</span> </span>
</div> </div>
); );
} }
return null;
// TODO: 支持自定义组件图标
return <span>{icon.content}</span>;
}; };
// 🎖️ 渲染配置徽章 // shadcn 风格连接点
const handleClass = `
!w-2.5 !h-2.5 !rounded-full !border-2 !border-background
transition-all duration-200
hover:!w-3 hover:!h-3
`;
const getHandleStyle = () => ({
background: config.theme.primary,
});
// 配置徽章 - shadcn badge 风格
const renderBadge = () => { const renderBadge = () => {
if (!config.features.showBadge) return null; if (!config.features.showBadge) return null;
if (!nodeData.configs || Object.keys(nodeData.configs).length <= 3) return null; if (!nodeData.configs || Object.keys(nodeData.configs).length <= 3) return null;
return ( return (
<div style={{ <div
position: 'absolute', className="
top: '-8px', absolute -top-1.5 -right-1.5
right: '-8px', inline-flex items-center justify-center
width: '20px', w-4 h-4 rounded-full
height: '20px', bg-primary text-primary-foreground
borderRadius: '50%', text-[9px] font-semibold
background: `linear-gradient(135deg, ${config.theme.primary} 0%, ${config.theme.secondary} 100%)`, border-2 border-background
border: '2px solid white', shadow-sm
fontSize: '10px', "
display: 'flex', >
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 'bold',
boxShadow: `0 2px 6px ${config.theme.primary}4D`,
}}>
</div> </div>
); );
}; };
// 📋 渲染 Hover 菜单
const renderHoverMenu = () => {
if (!config.features.showHoverMenu) return null;
if (!isHovered || selected) return null;
return ( return (
<div style={{ <div className="group">
position: 'absolute', <div className={getContainerClass()}>
top: '-12px', {/* 输入连接点 */}
right: '8px',
display: 'flex',
gap: '4px',
opacity: isHovered ? 1 : 0,
transition: 'opacity 0.2s ease',
}}>
<div style={{
width: '24px',
height: '24px',
borderRadius: '6px',
background: 'white',
border: '1px solid #e5e7eb',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
fontSize: '12px',
}} title="复制">
📋
</div>
</div>
);
};
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={getContainerStyle()}
>
{/* 🔌 输入连接点 */}
{config.handles.input && ( {config.handles.input && (
<Handle <Handle
type="target" type="target"
position={Position.Left} position={Position.Left}
className={handleClass}
style={getHandleStyle()} style={getHandleStyle()}
/> />
)} )}
{/* 📦 节点内容 */} {/* 图标 */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px'
}}>
{/* 🎭 图标 */}
{renderIcon()} {renderIcon()}
{/* 🏷️ 标签 */} {/* 标签 - shadcn 文本样式 */}
<div style={{ <div className="text-sm font-medium text-foreground whitespace-nowrap">
fontSize: '14px',
color: '#1f2937',
fontWeight: '600',
textAlign: 'center',
lineHeight: '1.2'
}}>
{nodeData.label || definition.nodeName} {nodeData.label || definition.nodeName}
</div> </div>
</div>
{/* 🔌 输出连接点 */} {/* 输出连接点 */}
{config.handles.output && ( {config.handles.output && (
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
className={handleClass}
style={getHandleStyle()} style={getHandleStyle()}
/> />
)} )}
{/* 🎖️ 配置徽章 */} {/* 配置徽章 */}
{renderBadge()} {renderBadge()}
</div>
{/* 📋 Hover 菜单 */}
{renderHoverMenu()}
</div> </div>
); );
}; };
export default React.memo(BaseNode); export default React.memo(BaseNode);

View File

@ -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 };
};