diff --git a/frontend/src/components/SelectOrVariableInput/index.tsx b/frontend/src/components/SelectOrVariableInput/index.tsx new file mode 100644 index 00000000..bbbad2c9 --- /dev/null +++ b/frontend/src/components/SelectOrVariableInput/index.tsx @@ -0,0 +1,177 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import VariableInput from '@/components/VariableInput'; +import { Button } from '@/components/ui/button'; +import { ArrowLeftRight } from 'lucide-react'; +import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types'; +import type { FormField } from '@/components/VariableInput/types'; + +export interface SelectOrVariableInputProps { + // 基本属性 + value: string | number | undefined; + onChange: (value: string | number) => void; + placeholder?: string; + disabled?: boolean; + + // 下拉选项 + options: Array<{ label: string; value: any }>; + + // 变量输入配置 + allNodes: FlowNode[]; + allEdges: FlowEdge[]; + currentNodeId: string; + formFields?: FormField[]; +} + +/** + * 混合输入组件:支持下拉选择 + 变量输入 + * + * 使用场景: + * - 固定值场景:用户从下拉框选择(如:选择 Jenkins 服务器) + * - 动态值场景:用户输入变量(如:${approval.jenkinsServerId}) + * + * 特性: + * - 自动识别值类型(包含 ${} → 变量模式) + * - 一键切换模式 + * - 保留变量输入的自动补全功能 + */ +export const SelectOrVariableInput: React.FC = ({ + value, + onChange, + placeholder = '请选择或输入变量', + disabled = false, + options, + allNodes, + allEdges, + currentNodeId, + formFields +}) => { + // 判断当前值是否为变量 + const isVariableValue = (val: any): boolean => { + return typeof val === 'string' && val.includes('${'); + }; + + // 模式:'select' 下拉选择 | 'variable' 变量输入 + const [mode, setMode] = useState<'select' | 'variable'>(() => { + return isVariableValue(value) ? 'variable' : 'select'; + }); + + // 内部显示值(用于切换时立即清空显示) + const [internalValue, setInternalValue] = useState(value); + + // 是否是手动切换(防止自动模式识别覆盖手动切换) + const isManualToggleRef = useRef(false); + + // 同步外部 value 到内部 internalValue(仅在非手动切换时) + useEffect(() => { + if (!isManualToggleRef.current) { + setInternalValue(value); + + // 自动模式识别 + if (isVariableValue(value) && mode === 'select') { + setMode('variable'); + } else if (typeof value === 'number' && mode === 'variable') { + setMode('select'); + } + } + }, [value, mode]); + + // 切换模式 + const handleToggleMode = (e: React.MouseEvent) => { + // 阻止默认行为和冒泡(防止触发表单提交) + e.preventDefault(); + e.stopPropagation(); + + const newMode = mode === 'select' ? 'variable' : 'select'; + + // 标记为手动切换 + isManualToggleRef.current = true; + + // 立即切换模式和清空值 + setMode(newMode); + setInternalValue(undefined); + onChange(undefined as any); + + // 短暂延迟后重置标记,允许后续同步 + requestAnimationFrame(() => { + isManualToggleRef.current = false; + }); + }; + + return ( +
+ {/* 主输入区域 */} +
+ {mode === 'select' ? ( + // 下拉选择模式 + + ) : ( + // 变量输入模式 + { + // 更新内部值 + setInternalValue(v || undefined); + + // 如果包含变量语法(${),认为是变量表达式 + if (v.includes('${')) { + // 变量表达式始终保持字符串类型 + onChange(v); + } else if (v === '') { + // 空值传递 undefined,符合表单 number 类型验证 + onChange(undefined as any); + } else { + // 普通文本:尝试转换为数字 + const numValue = Number(v); + onChange(isNaN(numValue) ? v : numValue); + } + }} + allNodes={allNodes} + allEdges={allEdges} + currentNodeId={currentNodeId} + formFields={formFields} + placeholder={placeholder} + disabled={disabled} + /> + )} +
+ + {/* 模式切换按钮 */} + +
+ ); +}; + +export default SelectOrVariableInput; + diff --git a/frontend/src/domain/dataSource/CascadeDataSourceService.ts b/frontend/src/domain/dataSource/CascadeDataSourceService.ts index c4b20982..aab329d7 100644 --- a/frontend/src/domain/dataSource/CascadeDataSourceService.ts +++ b/frontend/src/domain/dataSource/CascadeDataSourceService.ts @@ -3,217 +3,217 @@ * 提供懒加载级联数据功能 */ import request from '@/utils/request'; -import { CascadeDataSourceType, type CascadeOption, type CascadeLevelConfig } from './types'; -import { getCascadeDataSourceConfig } from './CascadeDataSourceRegistry'; +import {CascadeDataSourceType, type CascadeOption, type CascadeLevelConfig} from './types'; +import {getCascadeDataSourceConfig} from './CascadeDataSourceRegistry'; /** * 级联数据源服务类 */ class CascadeDataSourceService { - /** - * 判断配置是否为递归模式 - */ - private isRecursiveMode(config: any): boolean { - return !!config.recursive; - } - - /** - * 加载第一级数据 - * @param type 级联数据源类型 - * @returns 第一级选项列表 - */ - async loadFirstLevel(type: CascadeDataSourceType): Promise { - const config = getCascadeDataSourceConfig(type); - - if (!config) { - console.error(`❌ 级联数据源类型 ${type} 未配置`); - return []; + /** + * 判断配置是否为递归模式 + */ + private isRecursiveMode(config: any): boolean { + return !!config.recursive; } - // 递归模式 - if (this.isRecursiveMode(config)) { - return this.loadRecursiveLevel(config.recursive!, null); - } + /** + * 加载第一级数据 + * @param type 级联数据源类型 + * @returns 第一级选项列表 + */ + async loadFirstLevel(type: CascadeDataSourceType): Promise { + const config = getCascadeDataSourceConfig(type); - // 固定层级模式 - if (!config.levels || config.levels.length === 0) { - console.error(`❌ 级联数据源类型 ${type} 未配置层级`); - return []; - } - - return this.loadLevel(config.levels[0], null, config.levels.length > 1); - } - - /** - * 加载子级数据 - * @param type 级联数据源类型 - * @param selectedOptions 已选择的选项路径(如 [env1, project1]) - * @returns 下一级选项列表 - */ - async loadChildren( - type: CascadeDataSourceType, - selectedOptions: any[] - ): Promise { - const config = getCascadeDataSourceConfig(type); - - if (!config) { - console.error(`❌ 级联数据源类型 ${type} 未配置`); - return []; - } - - // 递归模式:使用同一配置加载所有层级 - if (this.isRecursiveMode(config)) { - const parentValue = selectedOptions[selectedOptions.length - 1]; - return this.loadRecursiveLevel(config.recursive!, parentValue); - } - - // 固定层级模式 - if (!config.levels) { - console.error(`❌ 级联数据源类型 ${type} 未配置层级`); - return []; - } - - const levelIndex = selectedOptions.length; - - if (levelIndex >= config.levels.length) { - console.warn(`⚠️ 级联层级 ${levelIndex} 超出配置范围`); - return []; - } - - const levelConfig = config.levels[levelIndex]; - const parentValue = selectedOptions[levelIndex - 1]; - const hasNextLevel = levelIndex + 1 < config.levels.length; - - return this.loadLevel(levelConfig, parentValue, hasNextLevel); - } - - /** - * 加载递归层级数据(无限层级) - * @param recursiveConfig 递归配置 - * @param parentValue 父级值(null 表示加载根节点) - * @returns 选项列表 - */ - private async loadRecursiveLevel( - recursiveConfig: any, - parentValue: any - ): Promise { - try { - // 构建请求参数 - const params: Record = { ...recursiveConfig.params }; - - // 添加父级参数 - if (parentValue !== null) { - params[recursiveConfig.parentParam] = parentValue; - } - - // 发起请求 - const response = await request.get(recursiveConfig.url, { params }); - const data = response || []; - - // 转换为级联选项格式 - return data.map((item: any) => { - const option: CascadeOption = { - label: item[recursiveConfig.labelField], - value: item[recursiveConfig.valueField] - }; - - // 判断是否为叶子节点 - if (recursiveConfig.hasChildren) { - // 使用自定义判断函数 - option.isLeaf = !recursiveConfig.hasChildren(item); - } else if ('isLeaf' in item) { - // 使用后端返回的 isLeaf 字段 - option.isLeaf = item.isLeaf; - } else if ('hasChildren' in item) { - // 使用后端返回的 hasChildren 字段 - option.isLeaf = !item.hasChildren; - } else { - // 默认不是叶子节点(允许继续展开) - option.isLeaf = false; + if (!config) { + console.error(`❌ 级联数据源类型 ${type} 未配置`); + return []; } - return option; - }); - } catch (error) { - console.error(`❌ 加载递归级联数据失败:`, error); - return []; - } - } - - /** - * 加载单个层级的数据(固定层级模式) - * @param levelConfig 层级配置 - * @param parentValue 父级值 - * @param hasNextLevel 是否有下一级 - * @returns 选项列表 - */ - private async loadLevel( - levelConfig: CascadeLevelConfig, - parentValue: any, - hasNextLevel: boolean - ): Promise { - try { - // 构建请求参数 - const params: Record = { ...levelConfig.params }; - - // 如果有父级值且配置了父级参数名,添加到请求参数中 - if (parentValue !== null && levelConfig.parentParam) { - params[levelConfig.parentParam] = parentValue; - } - - // 发起请求 - const response = await request.get(levelConfig.url, { params }); - const data = response || []; - - // 转换为级联选项格式 - return data.map((item: any) => { - const option: CascadeOption = { - label: item[levelConfig.labelField], - value: item[levelConfig.valueField] - }; - - // 判断是否为叶子节点 - if (levelConfig.isLeaf) { - option.isLeaf = levelConfig.isLeaf(item); - } else { - // 如果没有下一级配置,则为叶子节点 - option.isLeaf = !hasNextLevel; + // 递归模式 + if (this.isRecursiveMode(config)) { + return this.loadRecursiveLevel(config.recursive!, null); } - return option; - }); - } catch (error) { - console.error(`❌ 加载级联数据失败:`, error); - return []; - } - } + // 固定层级模式 + if (!config.levels || config.levels.length === 0) { + console.error(`❌ 级联数据源类型 ${type} 未配置层级`); + return []; + } - /** - * 获取级联配置的层级数 - * @param type 级联数据源类型 - * @returns 层级数(递归模式返回 -1 表示无限层级) - */ - getLevelCount(type: CascadeDataSourceType): number { - const config = getCascadeDataSourceConfig(type); - if (!config) return 0; - - // 递归模式返回 -1 表示无限层级 - if (this.isRecursiveMode(config)) { - return -1; + return this.loadLevel(config.levels[0], null, config.levels.length > 1); } - - return config.levels?.length || 0; - } - /** - * 获取级联配置的描述 - * @param type 级联数据源类型 - * @returns 描述文本 - */ - getDescription(type: CascadeDataSourceType): string { - const config = getCascadeDataSourceConfig(type); - return config?.description || ''; - } + /** + * 加载子级数据 + * @param type 级联数据源类型 + * @param selectedOptions 已选择的选项路径(如 [env1, project1]) + * @returns 下一级选项列表 + */ + async loadChildren( + type: CascadeDataSourceType, + selectedOptions: any[] + ): Promise { + const config = getCascadeDataSourceConfig(type); + + if (!config) { + console.error(`❌ 级联数据源类型 ${type} 未配置`); + return []; + } + + // 递归模式:使用同一配置加载所有层级 + if (this.isRecursiveMode(config)) { + const parentValue = selectedOptions[selectedOptions.length - 1]; + return this.loadRecursiveLevel(config.recursive!, parentValue); + } + + // 固定层级模式 + if (!config.levels) { + console.error(`❌ 级联数据源类型 ${type} 未配置层级`); + return []; + } + + const levelIndex = selectedOptions.length; + + if (levelIndex >= config.levels.length) { + console.warn(`⚠️ 级联层级 ${levelIndex} 超出配置范围`); + return []; + } + + const levelConfig = config.levels[levelIndex]; + const parentValue = selectedOptions[levelIndex - 1]; + const hasNextLevel = levelIndex + 1 < config.levels.length; + + return this.loadLevel(levelConfig, parentValue, hasNextLevel); + } + + /** + * 加载递归层级数据(无限层级) + * @param recursiveConfig 递归配置 + * @param parentValue 父级值(null 表示加载根节点) + * @returns 选项列表 + */ + private async loadRecursiveLevel( + recursiveConfig: any, + parentValue: any + ): Promise { + try { + // 构建请求参数 + const params: Record = {...recursiveConfig.params}; + + // 添加父级参数 + if (parentValue !== null) { + params[recursiveConfig.parentParam] = parentValue; + } + + // 发起请求 + const response = await request.get(recursiveConfig.url, {params}); + const data = response || []; + + // 转换为级联选项格式 + return data.map((item: any) => { + const option: CascadeOption = { + label: item[recursiveConfig.labelField], + value: item[recursiveConfig.valueField] + }; + + // 判断是否为叶子节点 + if (recursiveConfig.hasChildren) { + // 使用自定义判断函数 + option.isLeaf = !recursiveConfig.hasChildren(item); + } else if ('isLeaf' in item) { + // 使用后端返回的 isLeaf 字段 + option.isLeaf = item.isLeaf; + } else if ('hasChildren' in item) { + // 使用后端返回的 hasChildren 字段 + option.isLeaf = !item.hasChildren; + } else { + // 默认不是叶子节点(允许继续展开) + option.isLeaf = false; + } + + return option; + }); + } catch (error) { + console.error(`❌ 加载递归级联数据失败:`, error); + return []; + } + } + + /** + * 加载单个层级的数据(固定层级模式) + * @param levelConfig 层级配置 + * @param parentValue 父级值 + * @param hasNextLevel 是否有下一级 + * @returns 选项列表 + */ + private async loadLevel( + levelConfig: CascadeLevelConfig, + parentValue: any, + hasNextLevel: boolean + ): Promise { + try { + // 构建请求参数 + const params: Record = {...levelConfig.params}; + + // 如果有父级值且配置了父级参数名,添加到请求参数中 + if (parentValue !== null && levelConfig.parentParam) { + params[levelConfig.parentParam] = parentValue; + } + + // 发起请求 + const response = await request.get(levelConfig.url, {params}); + const data = response || []; + + // 转换为级联选项格式 + return data.map((item: any) => { + const option: CascadeOption = { + label: item[levelConfig.labelField], + value: item[levelConfig.valueField] + }; + + // 判断是否为叶子节点 + if (levelConfig.isLeaf) { + option.isLeaf = levelConfig.isLeaf(item); + } else { + // 如果没有下一级配置,则为叶子节点 + option.isLeaf = !hasNextLevel; + } + + return option; + }); + } catch (error) { + console.error(`❌ 加载级联数据失败:`, error); + return []; + } + } + + /** + * 获取级联配置的层级数 + * @param type 级联数据源类型 + * @returns 层级数(递归模式返回 -1 表示无限层级) + */ + getLevelCount(type: CascadeDataSourceType): number { + const config = getCascadeDataSourceConfig(type); + if (!config) return 0; + + // 递归模式返回 -1 表示无限层级 + if (this.isRecursiveMode(config)) { + return -1; + } + + return config.levels?.length || 0; + } + + /** + * 获取级联配置的描述 + * @param type 级联数据源类型 + * @returns 描述文本 + */ + getDescription(type: CascadeDataSourceType): string { + const config = getCascadeDataSourceConfig(type); + return config?.description || ''; + } } // 导出单例 @@ -221,8 +221,8 @@ export const cascadeDataSourceService = new CascadeDataSourceService(); // 向后兼容:导出函数式API export const loadCascadeFirstLevel = (type: CascadeDataSourceType) => - cascadeDataSourceService.loadFirstLevel(type); + cascadeDataSourceService.loadFirstLevel(type); export const loadCascadeChildren = (type: CascadeDataSourceType, selectedOptions: any[]) => - cascadeDataSourceService.loadChildren(type, selectedOptions); + cascadeDataSourceService.loadChildren(type, selectedOptions); diff --git a/frontend/src/domain/dataSource/DataSourceService.ts b/frontend/src/domain/dataSource/DataSourceService.ts index 89c7e34e..b69908e3 100644 --- a/frontend/src/domain/dataSource/DataSourceService.ts +++ b/frontend/src/domain/dataSource/DataSourceService.ts @@ -3,66 +3,77 @@ * 提供数据源加载和管理功能 */ import request from '@/utils/request'; -import { DataSourceType, type DataSourceOption } from './types'; -import { getDataSourceConfig } from './DataSourceRegistry'; +import {DataSourceType, type DataSourceOption} from './types'; +import {getDataSourceConfig, getAllDataSourceTypes} from './DataSourceRegistry'; /** * 数据源服务类 */ class DataSourceService { - /** - * 加载单个数据源 - * @param type 数据源类型 - * @returns 选项列表 - */ - async load(type: DataSourceType): Promise { - const config = getDataSourceConfig(type); + /** + * 加载单个数据源 + * @param type 数据源类型 + * @returns 选项列表 + */ + async load(type: DataSourceType | string): Promise { + const config = getDataSourceConfig(type as DataSourceType); - if (!config) { - console.error(`❌ 数据源类型 ${type} 未配置`); - return []; + if (!config) { + const registeredTypes = getAllDataSourceTypes(); + console.warn(`⚠️ 数据源类型 "${type}" 未配置,请检查: + +📋 可能的原因: +1. 节点定义中使用了字符串而非 DataSourceType 枚举(如 "jenkins-servers" 应改为 DataSourceType.JENKINS_SERVERS) +2. 该数据源尚未在 DataSourceRegistry 中注册 +3. 如果是新数据源,需要先在 src/domain/dataSource/presets/ 中添加配置并注册 + +✅ 当前已注册的数据源类型(${registeredTypes.length}个): +${registeredTypes.map(t => ` - ${t}`).join('\n')} + +💡 临时解决:返回空数组,界面将显示空下拉列表(可切换到变量输入模式)。`); + return []; + } + + try { + const response = await request.get(config.url, {params: config.params}); + // request 拦截器已经提取了 data 字段,response 直接是数组 + return config.transform(response || []); + } catch (error) { + console.error(`❌ 加载数据源 ${type} 失败:`, error); + return []; + } } - try { - const response = await request.get(config.url, { params: config.params }); - // request 拦截器已经提取了 data 字段,response 直接是数组 - return config.transform(response || []); - } catch (error) { - console.error(`❌ 加载数据源 ${type} 失败:`, error); - return []; + /** + * 批量加载多个数据源(用于预加载) + * @param types 数据源类型列表 + * @returns 数据源数据映射 + */ + async loadMultiple( + types: DataSourceType[] + ): Promise> { + const results = await Promise.all( + types.map(type => this.load(type)) + ); + + return types.reduce((acc, type, index) => { + acc[type] = results[index]; + return acc; + }, {} as Record); } - } - /** - * 批量加载多个数据源(用于预加载) - * @param types 数据源类型列表 - * @returns 数据源数据映射 - */ - async loadMultiple( - types: DataSourceType[] - ): Promise> { - const results = await Promise.all( - types.map(type => this.load(type)) - ); - - return types.reduce((acc, type, index) => { - acc[type] = results[index]; - return acc; - }, {} as Record); - } - - /** - * 预加载常用数据源(提升用户体验) - * @returns 预加载的数据源映射 - */ - async preload(): Promise> { - const commonTypes: DataSourceType[] = [ - DataSourceType.JENKINS_SERVERS, - DataSourceType.K8S_CLUSTERS, - DataSourceType.USERS - ]; - return this.loadMultiple(commonTypes); - } + /** + * 预加载常用数据源(提升用户体验) + * @returns 预加载的数据源映射 + */ + async preload(): Promise> { + const commonTypes: DataSourceType[] = [ + DataSourceType.JENKINS_SERVERS, + DataSourceType.K8S_CLUSTERS, + DataSourceType.USERS + ]; + return this.loadMultiple(commonTypes); + } } // 导出单例 diff --git a/frontend/src/domain/dataSource/presets/jenkins.ts b/frontend/src/domain/dataSource/presets/jenkins.ts index 212c6db2..774b9fde 100644 --- a/frontend/src/domain/dataSource/presets/jenkins.ts +++ b/frontend/src/domain/dataSource/presets/jenkins.ts @@ -1,18 +1,18 @@ /** * Jenkins 服务器数据源 */ -import type { DataSourceConfig } from '../types'; +import type {DataSourceConfig} from '../types'; export const jenkinsServersConfig: DataSourceConfig = { - url: '/api/v1/external-system/list', - params: { type: 'JENKINS', enabled: true }, - transform: (data: any[]) => { - return data.map((item: any) => ({ - label: `${item.name} (${item.url})`, - value: item.id, - url: item.url, - name: item.name - })); - } + url: '/api/v1/external-system/list', + params: {type: 'JENKINS', enabled: true}, + transform: (data: any[]) => { + return data.map((item: any) => ({ + label: `${item.name} (${item.url})`, + value: item.id, + url: item.url, + name: item.name + })); + } }; diff --git a/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx b/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx index 160afe94..fb3269f1 100644 --- a/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx +++ b/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -6,7 +6,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D import { Button } from '@/components/ui/button'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { useToast } from '@/components/ui/use-toast'; import type { FlowEdge, FlowNode } from '../types'; import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion'; @@ -29,24 +28,12 @@ export interface EdgeCondition { priority: number; } -// Zod 表单验证 Schema +// ✅ 简化的 Zod 验证 Schema(不再需要type字段) const edgeConditionSchema = z.object({ - type: z.enum(['EXPRESSION', 'DEFAULT'], { - required_error: '请选择条件类型', - }), - expression: z.string().optional(), + expression: z.string().optional(), // 可选,为空表示默认路径 priority: z.number() .min(1, '优先级最小为 1') - .max(999, '优先级最大为 999'), -}).refine((data) => { - // 如果是表达式类型,expression 必填 - if (data.type === 'EXPRESSION') { - return data.expression && data.expression.trim().length > 0; - } - return true; -}, { - message: '请输入条件表达式', - path: ['expression'], + .max(998, '优先级最大为 998'), // 条件分支的优先级范围 }); type EdgeConditionFormValues = z.infer; @@ -67,16 +54,18 @@ const EdgeConfigModal: React.FC = ({ }) => { // ✅ 所有 Hooks 必须在最顶部调用,不能放在条件语句后面 const { toast } = useToast(); - const [conditionType, setConditionType] = useState<'EXPRESSION' | 'DEFAULT'>('EXPRESSION'); const form = useForm({ resolver: zodResolver(edgeConditionSchema), defaultValues: { - type: 'EXPRESSION', expression: '', priority: 10, }, }); + + // 监听表达式字段,判断是否为默认路径 + const expression = form.watch('expression'); + const isDefaultBranch = !expression?.trim(); // 当 edge 变化时,更新表单值 useEffect(() => { @@ -88,13 +77,15 @@ const EdgeConfigModal: React.FC = ({ ? convertToDisplayName(condition.expression, allNodes) : ''; - const values = { - type: (condition?.type || 'EXPRESSION') as 'EXPRESSION' | 'DEFAULT', + // 如果是默认路径,不显示优先级(自动为999) + const priority = condition?.type === 'DEFAULT' + ? 10 // UI默认值 + : (condition?.priority || 10); + + form.reset({ expression: displayExpression, - priority: condition?.priority || 10, - }; - form.reset(values); - setConditionType(values.type); + priority, + }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible, edge?.id]); @@ -105,82 +96,42 @@ const EdgeConfigModal: React.FC = ({ // ✅ 网关类型存储在 inputMapping 中,不是 configs 中 const gatewayType = isFromGateway ? (sourceNode?.data?.inputMapping?.gatewayType || sourceNode?.data?.configs?.gatewayType) : null; - // 获取该网关的所有出口连线 - const gatewayEdges = isFromGateway && edge + // 获取源节点的所有出口连线(不管是不是网关节点) + const sourceEdges = edge ? allEdges.filter(e => e.source === edge.source) : []; - // 检查是否已有默认分支 - const hasDefaultBranch = gatewayEdges.some(e => e.data?.condition?.type === 'DEFAULT' && e.id !== edge?.id); + // 检查是否已有其他默认分支 + const hasOtherDefaultBranch = sourceEdges.some(e => + e.id !== edge?.id && + e.data?.condition?.type === 'DEFAULT' + ); // 检查优先级是否重复 - const usedPriorities = gatewayEdges + const usedPriorities = sourceEdges .filter(e => e.id !== edge?.id && e.data?.condition?.type === 'EXPRESSION') .map(e => e.data?.condition?.priority) .filter((p): p is number => p !== undefined); - - // ⚠️ 如果源节点不是网关节点,显示引导信息 - if (!isFromGateway) { - return ( - !open && onCancel()}> - - - 规范化提示 - - 为了符合 BPMN 2.0 标准,我们采用网关节点来处理分支逻辑 - - - -
-
-
- ℹ️ -
-

- 多分支场景请使用网关节点 -

-

- 网关节点提供了清晰的可视化分支逻辑,并且完全符合 BPMN 标准,便于: -

-
    -
  • 前后端数据结构一致(所见即所得)
  • -
  • 与主流工作流引擎(Camunda/Flowable)对接
  • -
  • 导出标准的 BPMN 2.0 XML
  • -
  • 支持复杂的分支逻辑(排他/并行/包容)
  • -
-
-
-
- -
-

💡 如何使用网关节点?

-
    -
  1. 从左侧节点面板拖入"网关节点"
  2. -
  3. 双击网关节点,选择类型(排他/并行/包容)
  4. -
  5. 连接源节点到网关节点
  6. -
  7. 连接网关节点到目标节点(可多条)
  8. -
  9. 双击网关的出口连线,配置分支条件
  10. -
-
-
- - - - - -
-
- ); - } const handleSubmit = (values: EdgeConditionFormValues) => { if (!edge) return; - // ⚠️ 网关节点必须先配置类型 + // 判断是否为默认路径(表达式为空) + const expr = values.expression?.trim(); + const isDefault = !expr; + + // 1. 检查多个默认分支 + if (isDefault && hasOtherDefaultBranch) { + toast({ + variant: 'destructive', + title: '冲突:多个默认分支', + description: `节点"${sourceNode?.data.label}"已有默认分支。一个节点只能有一个默认分支,请为此分支配置条件表达式。`, + duration: 5000, + }); + return; + } + + // 2. 网关节点必须先配置类型 if (isFromGateway && !gatewayType) { toast({ variant: 'destructive', @@ -191,62 +142,23 @@ const EdgeConfigModal: React.FC = ({ return; } - // 网关节点特殊验证 - if (isFromGateway && gatewayType) { - // 排他网关/包容网关:检查优先级重复 - if ((gatewayType === 'exclusiveGateway' || gatewayType === 'inclusiveGateway') && values.type === 'EXPRESSION') { - if (usedPriorities.includes(values.priority)) { - toast({ - variant: 'destructive', - title: '优先级冲突', - description: `优先级 ${values.priority} 已被其他分支使用。已使用的优先级:${usedPriorities.join(', ')}。请选择不同的优先级。`, - duration: 5000, - }); - return; - } - } - - // 检查默认分支唯一性 - if (values.type === 'DEFAULT' && hasDefaultBranch) { + // 3. 条件分支的验证 + if (!isDefault) { + // 3.1 检查优先级重复 + if (usedPriorities.includes(values.priority)) { toast({ variant: 'destructive', - title: '默认分支已存在', - description: `该网关已有默认分支,一个${gatewayType === 'exclusiveGateway' ? '排他' : '包容'}网关只能有一个默认分支。请将此分支设置为"表达式"类型,并配置条件表达式。`, - duration: 6000, + title: '优先级冲突', + description: `优先级 ${values.priority} 已被其他分支使用。已使用的优先级:${usedPriorities.join(', ')}。请选择不同的优先级。`, + duration: 5000, }); return; } - // 并行网关:不需要条件表达式(仅提示,不阻止) - if (gatewayType === 'parallelGateway' && values.type === 'EXPRESSION') { - toast({ - title: '💡 温馨提示', - description: '并行网关的所有分支都会同时执行,配置条件表达式不会影响执行逻辑。建议保持默认配置即可。', - duration: 4000, - }); - } + // 3.2 转换显示名称为 UUID + const uuidExpression = convertToUUID(expr, allNodes); - // 排他网关/包容网关:检查是否配置了条件表达式 - if ((gatewayType === 'exclusiveGateway' || gatewayType === 'inclusiveGateway') && values.type === 'EXPRESSION') { - if (!values.expression || values.expression.trim() === '') { - toast({ - variant: 'destructive', - title: '条件表达式为空', - description: '请输入条件表达式,或选择"默认路径"类型。表达式格式:${上游节点名称.字段名 == \'值\'}', - duration: 5000, - }); - return; - } - } - } - - // 转换 expression 中的显示名称为 UUID - const uuidExpression = values.expression - ? convertToUUID(values.expression, allNodes) - : ''; - - // 检查表达式是否包含变量引用(排他/包容网关的表达式必须包含变量) - if (isFromGateway && (gatewayType === 'exclusiveGateway' || gatewayType === 'inclusiveGateway') && values.type === 'EXPRESSION') { + // 3.3 检查是否包含变量引用 const hasVariable = /\$\{[^}]+\}/.test(uuidExpression); if (!hasVariable) { toast({ @@ -257,30 +169,44 @@ const EdgeConfigModal: React.FC = ({ }); return; } + + // 3.4 并行网关提示(不阻止) + if (isFromGateway && gatewayType === 'parallelGateway') { + toast({ + title: '💡 温馨提示', + description: '并行网关的所有分支都会同时执行,配置条件表达式不会影响执行逻辑。建议保持默认配置即可。', + duration: 4000, + }); + } } - // ✅ 提交成功提示 + // 4. 构建条件数据 + const condition: EdgeCondition = isDefault + ? { + type: 'DEFAULT', + priority: 999 // 默认路径自动设为最低优先级 + } + : { + type: 'EXPRESSION', + expression: convertToUUID(expr, allNodes), + priority: values.priority + }; + + // 5. 成功提示 toast({ title: '✅ 保存成功', - description: values.type === 'DEFAULT' - ? '已设置为默认分支' - : `已设置条件表达式,优先级:${values.priority}`, + description: isDefault + ? '已设置为默认路径(当所有其他条件都不满足时执行)' + : `已设置条件分支,优先级:${values.priority}`, duration: 2000, }); - // 提交 UUID 格式的数据 - onOk(edge.id, { - type: values.type, - expression: uuidExpression, - // 默认分支自动设置优先级为 999(最低优先级,最后执行) - priority: values.type === 'DEFAULT' ? 999 : values.priority - }); + onOk(edge.id, condition); handleClose(); }; const handleClose = () => { form.reset(); - setConditionType('EXPRESSION'); onCancel(); }; @@ -333,8 +259,8 @@ const EdgeConfigModal: React.FC = ({ )} - {/* 🔴 错误提示:多个默认分支(仅排他/包容网关,且用户选择 DEFAULT 类型时显示) */} - {isFromGateway && gatewayType && gatewayType !== 'parallelGateway' && hasDefaultBranch && conditionType === 'DEFAULT' && ( + {/* 🔴 错误提示:多个默认分支(网关节点) */} + {isFromGateway && gatewayType && gatewayType !== 'parallelGateway' && hasOtherDefaultBranch && isDefaultBranch && (
@@ -353,64 +279,6 @@ const EdgeConfigModal: React.FC = ({
)} - {/* ℹ️ 网关规则说明 */} - {isFromGateway && gatewayType && ( -
-
- ℹ️ -
- {gatewayType === 'exclusiveGateway' && ( - <> -

排他网关规则:

-
    -
  • 按优先级顺序评估条件(数字越小越优先)
  • -
  • 第一个满足的条件分支被执行,其他分支跳过
  • -
  • 必须有一个默认分支(当所有条件都不满足时执行)
  • -
  • 已使用的优先级:{usedPriorities.length > 0 ? usedPriorities.join(', ') : '无'}
  • -
-
-

💡 条件表达式示例:

- - ${'{上游节点名称.status == \'SUCCESS\'}'} - -
- - )} - {gatewayType === 'parallelGateway' && ( - <> -

并行网关规则:

-
    -
  • 所有出口分支同时执行(Fork模式)
  • -
  • 不需要条件表达式(无条件执行)
  • -
  • 下游汇聚点等待所有分支完成(Join模式)
  • -
-
-

- 💡 并行网关的所有边线可以保持默认配置(自动执行) -

-
- - )} - {gatewayType === 'inclusiveGateway' && ( - <> -

包容网关规则:

-
    -
  • 所有满足条件的分支都会执行(可能是1条或多条)
  • -
  • 建议为每条分支设置条件表达式
  • -
  • 默认分支在所有条件不满足时执行
  • -
-
-

💡 条件表达式示例:

- - ${'{上游节点.score >= 80}'} - -
- - )} -
-
-
- )} {/* 并行网关:直接显示说明,不允许配置条件 */} {isFromGateway && gatewayType === 'parallelGateway' ? ( @@ -433,55 +301,41 @@ const EdgeConfigModal: React.FC = ({ ) : (
- ( - - 条件类型 - -
-
- { - field.onChange(e.target.value); - setConditionType('EXPRESSION'); - }} - className="h-4 w-4" - /> - -
-
- { - field.onChange(e.target.value); - setConditionType('DEFAULT'); - }} - className="h-4 w-4" - /> -