更改目录结构

This commit is contained in:
dengqichen 2025-11-03 17:27:48 +08:00
parent 3307b5cb46
commit ae2690899f
10 changed files with 657 additions and 566 deletions

View File

@ -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<SelectOrVariableInputProps> = ({
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<string | number | undefined>(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<HTMLButtonElement>) => {
// 阻止默认行为和冒泡(防止触发表单提交)
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 (
<div className="flex items-center gap-2">
{/* 主输入区域 */}
<div className="flex-1">
{mode === 'select' ? (
// 下拉选择模式
<Select
disabled={disabled}
value={internalValue?.toString() || ''}
onValueChange={(v) => {
// 尝试转换为数字如果原值是number类型
const numValue = Number(v);
const newValue = isNaN(numValue) ? v : numValue;
setInternalValue(newValue);
onChange(newValue);
}}
>
<SelectTrigger>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value.toString()}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
// 变量输入模式
<VariableInput
value={String(internalValue || '')}
onChange={(v) => {
// 更新内部值
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}
/>
)}
</div>
{/* 模式切换按钮 */}
<Button
type="button"
variant="outline"
size="icon"
onClick={handleToggleMode}
disabled={disabled}
title={mode === 'select' ? '切换到变量输入' : '切换到下拉选择'}
className="shrink-0"
>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
);
};
export default SelectOrVariableInput;

View File

@ -3,217 +3,217 @@
* *
*/ */
import request from '@/utils/request'; import request from '@/utils/request';
import { CascadeDataSourceType, type CascadeOption, type CascadeLevelConfig } from './types'; import {CascadeDataSourceType, type CascadeOption, type CascadeLevelConfig} from './types';
import { getCascadeDataSourceConfig } from './CascadeDataSourceRegistry'; import {getCascadeDataSourceConfig} from './CascadeDataSourceRegistry';
/** /**
* *
*/ */
class CascadeDataSourceService { class CascadeDataSourceService {
/** /**
* *
*/ */
private isRecursiveMode(config: any): boolean { private isRecursiveMode(config: any): boolean {
return !!config.recursive; return !!config.recursive;
}
/**
*
* @param type
* @returns
*/
async loadFirstLevel(type: CascadeDataSourceType): Promise<CascadeOption[]> {
const config = getCascadeDataSourceConfig(type);
if (!config) {
console.error(`❌ 级联数据源类型 ${type} 未配置`);
return [];
} }
// 递归模式 /**
if (this.isRecursiveMode(config)) { *
return this.loadRecursiveLevel(config.recursive!, null); * @param type
} * @returns
*/
async loadFirstLevel(type: CascadeDataSourceType): Promise<CascadeOption[]> {
const config = getCascadeDataSourceConfig(type);
// 固定层级模式 if (!config) {
if (!config.levels || config.levels.length === 0) { console.error(`❌ 级联数据源类型 ${type} 未配置`);
console.error(`❌ 级联数据源类型 ${type} 未配置层级`); return [];
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<CascadeOption[]> {
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<CascadeOption[]> {
try {
// 构建请求参数
const params: Record<string, any> = { ...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; // 递归模式
}); if (this.isRecursiveMode(config)) {
} catch (error) { return this.loadRecursiveLevel(config.recursive!, null);
console.error(`❌ 加载递归级联数据失败:`, error);
return [];
}
}
/**
*
* @param levelConfig
* @param parentValue
* @param hasNextLevel
* @returns
*/
private async loadLevel(
levelConfig: CascadeLevelConfig,
parentValue: any,
hasNextLevel: boolean
): Promise<CascadeOption[]> {
try {
// 构建请求参数
const params: Record<string, any> = { ...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; // 固定层级模式
}); if (!config.levels || config.levels.length === 0) {
} catch (error) { console.error(`❌ 级联数据源类型 ${type} 未配置层级`);
console.error(`❌ 加载级联数据失败:`, error); return [];
return []; }
}
}
/** return this.loadLevel(config.levels[0], null, config.levels.length > 1);
*
* @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 * @param type
* @returns * @param selectedOptions [env1, project1]
*/ * @returns
getDescription(type: CascadeDataSourceType): string { */
const config = getCascadeDataSourceConfig(type); async loadChildren(
return config?.description || ''; type: CascadeDataSourceType,
} selectedOptions: any[]
): Promise<CascadeOption[]> {
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<CascadeOption[]> {
try {
// 构建请求参数
const params: Record<string, any> = {...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<CascadeOption[]> {
try {
// 构建请求参数
const params: Record<string, any> = {...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 // 向后兼容导出函数式API
export const loadCascadeFirstLevel = (type: CascadeDataSourceType) => export const loadCascadeFirstLevel = (type: CascadeDataSourceType) =>
cascadeDataSourceService.loadFirstLevel(type); cascadeDataSourceService.loadFirstLevel(type);
export const loadCascadeChildren = (type: CascadeDataSourceType, selectedOptions: any[]) => export const loadCascadeChildren = (type: CascadeDataSourceType, selectedOptions: any[]) =>
cascadeDataSourceService.loadChildren(type, selectedOptions); cascadeDataSourceService.loadChildren(type, selectedOptions);

View File

@ -3,66 +3,77 @@
* *
*/ */
import request from '@/utils/request'; import request from '@/utils/request';
import { DataSourceType, type DataSourceOption } from './types'; import {DataSourceType, type DataSourceOption} from './types';
import { getDataSourceConfig } from './DataSourceRegistry'; import {getDataSourceConfig, getAllDataSourceTypes} from './DataSourceRegistry';
/** /**
* *
*/ */
class DataSourceService { class DataSourceService {
/** /**
* *
* @param type * @param type
* @returns * @returns
*/ */
async load(type: DataSourceType): Promise<DataSourceOption[]> { async load(type: DataSourceType | string): Promise<DataSourceOption[]> {
const config = getDataSourceConfig(type); const config = getDataSourceConfig(type as DataSourceType);
if (!config) { if (!config) {
console.error(`❌ 数据源类型 ${type} 未配置`); const registeredTypes = getAllDataSourceTypes();
return []; 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 直接是数组 * @param types
return config.transform(response || []); * @returns
} catch (error) { */
console.error(`❌ 加载数据源 ${type} 失败:`, error); async loadMultiple(
return []; types: DataSourceType[]
): Promise<Record<DataSourceType, DataSourceOption[]>> {
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<DataSourceType, DataSourceOption[]>);
} }
}
/** /**
* *
* @param types * @returns
* @returns */
*/ async preload(): Promise<Record<DataSourceType, DataSourceOption[]>> {
async loadMultiple( const commonTypes: DataSourceType[] = [
types: DataSourceType[] DataSourceType.JENKINS_SERVERS,
): Promise<Record<DataSourceType, DataSourceOption[]>> { DataSourceType.K8S_CLUSTERS,
const results = await Promise.all( DataSourceType.USERS
types.map(type => this.load(type)) ];
); return this.loadMultiple(commonTypes);
}
return types.reduce((acc, type, index) => {
acc[type] = results[index];
return acc;
}, {} as Record<DataSourceType, DataSourceOption[]>);
}
/**
*
* @returns
*/
async preload(): Promise<Record<DataSourceType, DataSourceOption[]>> {
const commonTypes: DataSourceType[] = [
DataSourceType.JENKINS_SERVERS,
DataSourceType.K8S_CLUSTERS,
DataSourceType.USERS
];
return this.loadMultiple(commonTypes);
}
} }
// 导出单例 // 导出单例

View File

@ -1,18 +1,18 @@
/** /**
* Jenkins * Jenkins
*/ */
import type { DataSourceConfig } from '../types'; import type {DataSourceConfig} from '../types';
export const jenkinsServersConfig: DataSourceConfig = { export const jenkinsServersConfig: DataSourceConfig = {
url: '/api/v1/external-system/list', url: '/api/v1/external-system/list',
params: { type: 'JENKINS', enabled: true }, params: {type: 'JENKINS', enabled: true},
transform: (data: any[]) => { transform: (data: any[]) => {
return data.map((item: any) => ({ return data.map((item: any) => ({
label: `${item.name} (${item.url})`, label: `${item.name} (${item.url})`,
value: item.id, value: item.id,
url: item.url, url: item.url,
name: item.name name: item.name
})); }));
} }
}; };

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod'; import * as z from 'zod';
@ -6,7 +6,6 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import type { FlowEdge, FlowNode } from '../types'; import type { FlowEdge, FlowNode } from '../types';
import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion'; import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion';
@ -29,24 +28,12 @@ export interface EdgeCondition {
priority: number; priority: number;
} }
// Zod 表单验证 Schema // ✅ 简化的 Zod 验证 Schema不再需要type字段
const edgeConditionSchema = z.object({ const edgeConditionSchema = z.object({
type: z.enum(['EXPRESSION', 'DEFAULT'], { expression: z.string().optional(), // 可选,为空表示默认路径
required_error: '请选择条件类型',
}),
expression: z.string().optional(),
priority: z.number() priority: z.number()
.min(1, '优先级最小为 1') .min(1, '优先级最小为 1')
.max(999, '优先级最大为 999'), .max(998, '优先级最大为 998'), // 条件分支的优先级范围
}).refine((data) => {
// 如果是表达式类型expression 必填
if (data.type === 'EXPRESSION') {
return data.expression && data.expression.trim().length > 0;
}
return true;
}, {
message: '请输入条件表达式',
path: ['expression'],
}); });
type EdgeConditionFormValues = z.infer<typeof edgeConditionSchema>; type EdgeConditionFormValues = z.infer<typeof edgeConditionSchema>;
@ -67,16 +54,18 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
}) => { }) => {
// ✅ 所有 Hooks 必须在最顶部调用,不能放在条件语句后面 // ✅ 所有 Hooks 必须在最顶部调用,不能放在条件语句后面
const { toast } = useToast(); const { toast } = useToast();
const [conditionType, setConditionType] = useState<'EXPRESSION' | 'DEFAULT'>('EXPRESSION');
const form = useForm<EdgeConditionFormValues>({ const form = useForm<EdgeConditionFormValues>({
resolver: zodResolver(edgeConditionSchema), resolver: zodResolver(edgeConditionSchema),
defaultValues: { defaultValues: {
type: 'EXPRESSION',
expression: '', expression: '',
priority: 10, priority: 10,
}, },
}); });
// 监听表达式字段,判断是否为默认路径
const expression = form.watch('expression');
const isDefaultBranch = !expression?.trim();
// 当 edge 变化时,更新表单值 // 当 edge 变化时,更新表单值
useEffect(() => { useEffect(() => {
@ -88,13 +77,15 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
? convertToDisplayName(condition.expression, allNodes) ? convertToDisplayName(condition.expression, allNodes)
: ''; : '';
const values = { // 如果是默认路径不显示优先级自动为999
type: (condition?.type || 'EXPRESSION') as 'EXPRESSION' | 'DEFAULT', const priority = condition?.type === 'DEFAULT'
? 10 // UI默认值
: (condition?.priority || 10);
form.reset({
expression: displayExpression, expression: displayExpression,
priority: condition?.priority || 10, priority,
}; });
form.reset(values);
setConditionType(values.type);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, edge?.id]); }, [visible, edge?.id]);
@ -105,82 +96,42 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
// ✅ 网关类型存储在 inputMapping 中,不是 configs 中 // ✅ 网关类型存储在 inputMapping 中,不是 configs 中
const gatewayType = isFromGateway ? (sourceNode?.data?.inputMapping?.gatewayType || sourceNode?.data?.configs?.gatewayType) : null; 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) ? 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') .filter(e => e.id !== edge?.id && e.data?.condition?.type === 'EXPRESSION')
.map(e => e.data?.condition?.priority) .map(e => e.data?.condition?.priority)
.filter((p): p is number => p !== undefined); .filter((p): p is number => p !== undefined);
// ⚠️ 如果源节点不是网关节点,显示引导信息
if (!isFromGateway) {
return (
<Dialog open={visible} onOpenChange={(open) => !open && onCancel()}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
BPMN 2.0
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="rounded-md bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 p-4">
<div className="flex items-start gap-3">
<span className="text-2xl"></span>
<div className="flex-1 space-y-2">
<p className="font-medium text-blue-900 dark:text-blue-200">
使
</p>
<p className="text-sm text-blue-800 dark:text-blue-300">
BPMN 便
</p>
<ul className="list-disc list-inside text-sm text-blue-800 dark:text-blue-300 space-y-1 ml-2">
<li></li>
<li>Camunda/Flowable</li>
<li> BPMN 2.0 XML</li>
<li>//</li>
</ul>
</div>
</div>
</div>
<div className="rounded-md bg-muted p-4">
<p className="text-sm font-medium mb-2">💡 使</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground space-y-1 ml-2">
<li>"网关节点"</li>
<li>//</li>
<li></li>
<li></li>
<li>线</li>
</ol>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
</Button>
<Button onClick={onCancel}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
const handleSubmit = (values: EdgeConditionFormValues) => { const handleSubmit = (values: EdgeConditionFormValues) => {
if (!edge) return; 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) { if (isFromGateway && !gatewayType) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
@ -191,62 +142,23 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
return; return;
} }
// 网关节点特殊验证 // 3. 条件分支的验证
if (isFromGateway && gatewayType) { if (!isDefault) {
// 排他网关/包容网关:检查优先级重复 // 3.1 检查优先级重复
if ((gatewayType === 'exclusiveGateway' || gatewayType === 'inclusiveGateway') && values.type === 'EXPRESSION') { if (usedPriorities.includes(values.priority)) {
if (usedPriorities.includes(values.priority)) {
toast({
variant: 'destructive',
title: '优先级冲突',
description: `优先级 ${values.priority} 已被其他分支使用。已使用的优先级:${usedPriorities.join(', ')}。请选择不同的优先级。`,
duration: 5000,
});
return;
}
}
// 检查默认分支唯一性
if (values.type === 'DEFAULT' && hasDefaultBranch) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: '默认分支已存在', title: '优先级冲突',
description: `该网关已有默认分支,一个${gatewayType === 'exclusiveGateway' ? '排他' : '包容'}网关只能有一个默认分支。请将此分支设置为"表达式"类型,并配置条件表达式`, description: `优先级 ${values.priority} 已被其他分支使用。已使用的优先级:${usedPriorities.join(', ')}。请选择不同的优先级。`,
duration: 6000, duration: 5000,
}); });
return; return;
} }
// 并行网关:不需要条件表达式(仅提示,不阻止) // 3.2 转换显示名称为 UUID
if (gatewayType === 'parallelGateway' && values.type === 'EXPRESSION') { const uuidExpression = convertToUUID(expr, allNodes);
toast({
title: '💡 温馨提示',
description: '并行网关的所有分支都会同时执行,配置条件表达式不会影响执行逻辑。建议保持默认配置即可。',
duration: 4000,
});
}
// 排他网关/包容网关:检查是否配置了条件表达式 // 3.3 检查是否包含变量引用
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') {
const hasVariable = /\$\{[^}]+\}/.test(uuidExpression); const hasVariable = /\$\{[^}]+\}/.test(uuidExpression);
if (!hasVariable) { if (!hasVariable) {
toast({ toast({
@ -257,30 +169,44 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
}); });
return; 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({ toast({
title: '✅ 保存成功', title: '✅ 保存成功',
description: values.type === 'DEFAULT' description: isDefault
? '已设置为默认分支' ? '已设置为默认路径(当所有其他条件都不满足时执行)'
: `已设置条件表达式,优先级:${values.priority}`, : `已设置条件分支,优先级:${values.priority}`,
duration: 2000, duration: 2000,
}); });
// 提交 UUID 格式的数据 onOk(edge.id, condition);
onOk(edge.id, {
type: values.type,
expression: uuidExpression,
// 默认分支自动设置优先级为 999最低优先级最后执行
priority: values.type === 'DEFAULT' ? 999 : values.priority
});
handleClose(); handleClose();
}; };
const handleClose = () => { const handleClose = () => {
form.reset(); form.reset();
setConditionType('EXPRESSION');
onCancel(); onCancel();
}; };
@ -333,8 +259,8 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
</div> </div>
)} )}
{/* 🔴 错误提示:多个默认分支(仅排他/包容网关,且用户选择 DEFAULT 类型时显示 */} {/* 🔴 错误提示:多个默认分支(网关节点 */}
{isFromGateway && gatewayType && gatewayType !== 'parallelGateway' && hasDefaultBranch && conditionType === 'DEFAULT' && ( {isFromGateway && gatewayType && gatewayType !== 'parallelGateway' && hasOtherDefaultBranch && isDefaultBranch && (
<div className="rounded-md bg-red-50 dark:bg-red-950/20 border-2 border-red-400 dark:border-red-600 p-4 text-sm mb-6"> <div className="rounded-md bg-red-50 dark:bg-red-950/20 border-2 border-red-400 dark:border-red-600 p-4 text-sm mb-6">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<span className="text-2xl"></span> <span className="text-2xl"></span>
@ -353,64 +279,6 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
</div> </div>
)} )}
{/* 网关规则说明 */}
{isFromGateway && gatewayType && (
<div className="rounded-md bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 p-3 text-sm mb-6">
<div className="flex items-start gap-2">
<span className="text-blue-600 dark:text-blue-400"></span>
<div className="flex-1 space-y-1">
{gatewayType === 'exclusiveGateway' && (
<>
<p className="font-medium text-blue-900 dark:text-blue-200"></p>
<ul className="list-disc list-inside text-blue-800 dark:text-blue-300 space-y-0.5">
<li></li>
<li></li>
<li></li>
<li>使{usedPriorities.length > 0 ? usedPriorities.join(', ') : '无'}</li>
</ul>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900/30 rounded">
<p className="font-medium text-blue-900 dark:text-blue-200 text-xs">💡 </p>
<code className="text-xs text-blue-800 dark:text-blue-300">
${'{上游节点名称.status == \'SUCCESS\'}'}
</code>
</div>
</>
)}
{gatewayType === 'parallelGateway' && (
<>
<p className="font-medium text-blue-900 dark:text-blue-200"></p>
<ul className="list-disc list-inside text-blue-800 dark:text-blue-300 space-y-0.5">
<li>Fork模式</li>
<li></li>
<li>Join模式</li>
</ul>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900/30 rounded">
<p className="text-xs text-blue-800 dark:text-blue-300">
💡 线
</p>
</div>
</>
)}
{gatewayType === 'inclusiveGateway' && (
<>
<p className="font-medium text-blue-900 dark:text-blue-200"></p>
<ul className="list-disc list-inside text-blue-800 dark:text-blue-300 space-y-0.5">
<li>1</li>
<li></li>
<li></li>
</ul>
<div className="mt-2 p-2 bg-blue-100 dark:bg-blue-900/30 rounded">
<p className="font-medium text-blue-900 dark:text-blue-200 text-xs">💡 </p>
<code className="text-xs text-blue-800 dark:text-blue-300">
${'{上游节点.score >= 80}'}
</code>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* 并行网关:直接显示说明,不允许配置条件 */} {/* 并行网关:直接显示说明,不允许配置条件 */}
{isFromGateway && gatewayType === 'parallelGateway' ? ( {isFromGateway && gatewayType === 'parallelGateway' ? (
@ -433,55 +301,41 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
) : ( ) : (
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6"> <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
<FormField {/* ✅ 智能提示:默认路径说明 */}
control={form.control} {isDefaultBranch && (
name="type" <div className="rounded-md bg-blue-50 dark:bg-blue-950/20 border border-blue-200 dark:border-blue-800 p-4 text-sm">
render={({ field }) => ( <div className="flex items-start gap-2">
<FormItem className="space-y-3"> <span className="text-blue-600 dark:text-blue-400"></span>
<FormLabel></FormLabel> <div className="flex-1 space-y-2">
<FormControl> <p className="font-medium text-blue-900 dark:text-blue-200">
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<input
type="radio"
id="type-expression"
value="EXPRESSION"
checked={field.value === 'EXPRESSION'}
onChange={(e) => {
field.onChange(e.target.value);
setConditionType('EXPRESSION');
}}
className="h-4 w-4"
/>
<Label htmlFor="type-expression" className="cursor-pointer font-normal">
</Label>
</div>
<div className="flex items-center space-x-2">
<input
type="radio"
id="type-default"
value="DEFAULT"
checked={field.value === 'DEFAULT'}
onChange={(e) => {
field.onChange(e.target.value);
setConditionType('DEFAULT');
}}
className="h-4 w-4"
/>
<Label htmlFor="type-default" className="cursor-pointer font-normal">
</Label> </p>
<p className="text-blue-800 dark:text-blue-300">
<strong></strong>999
</p>
</div> </div>
</div> </div>
</FormControl> </div>
<FormMessage /> )}
</FormItem>
)} {/* ✅ 错误提示:多个默认分支 */}
/> {isDefaultBranch && hasOtherDefaultBranch && (
<div className="rounded-md bg-red-50 dark:bg-red-950/20 border-2 border-red-400 dark:border-red-600 p-4 text-sm">
<div className="flex items-start gap-2">
<span className="text-red-600 dark:text-red-400"></span>
<div className="flex-1 space-y-2">
<p className="font-bold text-red-900 dark:text-red-200">
</p>
<p className="text-red-800 dark:text-red-300">
"<strong>{sourceNode?.data.label}</strong>"
</p>
</div>
</div>
</div>
)}
{conditionType === 'EXPRESSION' ? ( {/* ✅ 条件表达式输入(核心字段) */}
<>
<FormField <FormField
control={form.control} control={form.control}
name="expression" name="expression"
@ -497,50 +351,46 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
currentNodeId={edge?.target || ''} currentNodeId={edge?.target || ''}
formFields={formFields} formFields={formFields}
variant="textarea" variant="textarea"
placeholder="请输入条件表达式,如:${form.环境选择} 或 ${Jenkins构建.buildStatus == 'SUCCESS'}" placeholder="例如: ${Jenkins构建.status} == 'SUCCESS',留空表示默认路径"
rows={4} rows={4}
/> />
</FormControl> </FormControl>
<FormDescription> <FormDescription>
使 JUEL {'${}'} {'}'} == 'SUCCESS'==!=&gt;&lt;&amp;&amp;|| 💡 <strong></strong>
使 JUEL ==!=&gt;&lt;&amp;&amp;||
</FormDescription> </FormDescription>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<FormField {/* ✅ 优先级(仅条件分支显示) */}
control={form.control} {!isDefaultBranch && (
name="priority" <FormField
render={({ field }) => ( control={form.control}
<FormItem> name="priority"
<FormLabel></FormLabel> render={({ field }) => (
<FormControl> <FormItem>
<Input <FormLabel></FormLabel>
type="number" <FormControl>
min={1} <Input
max={999} type="number"
placeholder="请输入优先级" min={1}
{...field} max={998}
onChange={(e) => field.onChange(Number(e.target.value))} placeholder="请输入优先级(数字越小越优先)"
/> {...field}
</FormControl> onChange={(e) => field.onChange(Number(e.target.value))}
<FormDescription> />
1-999使{usedPriorities.length > 0 ? usedPriorities.join(', ') : '无'} </FormControl>
</FormDescription> <FormDescription>
<FormMessage /> 1-998999
</FormItem> {usedPriorities.length > 0 && ` 已使用:${usedPriorities.join(', ')}`}
)} </FormDescription>
/> <FormMessage />
</> </FormItem>
) : ( )}
<div className="rounded-md bg-muted p-4 text-sm"> />
<p className="font-medium text-foreground mb-2"></p> )}
<p className="text-muted-foreground">
</p>
</div>
)}
</form> </form>
</Form> </Form>
)} )}

View File

@ -22,6 +22,7 @@ import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaC
import { loadDataSource, DataSourceType, type DataSourceOption } from '@/domain/dataSource'; import { loadDataSource, DataSourceType, type DataSourceOption } from '@/domain/dataSource';
import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion'; import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion';
import VariableInput from '@/components/VariableInput'; import VariableInput from '@/components/VariableInput';
import SelectOrVariableInput from '@/components/SelectOrVariableInput';
import type { FormField as FormFieldType } from '@/components/VariableInput/types'; import type { FormField as FormFieldType } from '@/components/VariableInput/types';
interface NodeConfigModalProps { interface NodeConfigModalProps {
@ -455,7 +456,24 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
); );
} }
// ✅ 单选类型 // ✅ 单选类型 + 支持变量输入
if (prop['x-allow-variable']) {
return (
<SelectOrVariableInput
value={field.value}
onChange={field.onChange}
options={options}
allNodes={allNodesRef.current}
allEdges={allEdgesRef.current}
currentNodeId={node?.id || ''}
formFields={formFields}
placeholder={prop.description || `请选择${prop.title || ''}或输入变量`}
disabled={loadingDataSources || loading}
/>
);
}
// ✅ 单选类型(纯下拉)
return renderSelect( return renderSelect(
options, options,
field, field,
@ -476,6 +494,25 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
const enumLabel = typeof value === 'object' ? value.label : (prop.enumNames?.[index] || value); const enumLabel = typeof value === 'object' ? value.label : (prop.enumNames?.[index] || value);
return { label: enumLabel, value: enumValue }; return { label: enumLabel, value: enumValue };
}); });
// ✅ 支持变量输入
if (prop['x-allow-variable']) {
return (
<SelectOrVariableInput
value={field.value}
onChange={field.onChange}
options={options}
allNodes={allNodesRef.current}
allEdges={allEdgesRef.current}
currentNodeId={node?.id || ''}
formFields={formFields}
placeholder={prop.description || `请选择${prop.title || ''}或输入变量`}
disabled={loading}
/>
);
}
// ✅ 纯下拉
return renderSelect(options, field, prop, loading); return renderSelect(options, field, prop, loading);
} }

View File

@ -1,4 +1,5 @@
import {ConfigurableNodeDefinition, NodeType, NodeCategory, defineNodeOutputs} from './types'; import {ConfigurableNodeDefinition, NodeType, NodeCategory, defineNodeOutputs} from './types';
import { DataSourceType } from '@/domain/dataSource';
/** /**
* Jenkins构建节点定义 * Jenkins构建节点定义
@ -44,16 +45,19 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = {
description: "当前节点所需数据配置", description: "当前节点所需数据配置",
properties: { properties: {
serverId: { serverId: {
type: "string", type: "number",
title: "服务器", title: "Jenkins服务器",
description: "输入要使用的服务器", description: "选择Jenkins服务器或输入变量",
default: "${jenkins.serverId}" "x-dataSource": DataSourceType.JENKINS_SERVERS,
"x-allow-variable": true
}, },
jobName: { jobName: {
type: "string", type: "string",
title: "项目", title: "构建任务",
description: "要触发构建的项目", description: "输入构建任务名称或输入变量",
default: "${jenkins.jobName}" // 注意jobName 暂时使用手动输入,因为需要先选择 serverId 才能级联加载 jobs
// 未来如果需要级联下拉,需要使用 CascadeDataSourceType.JENKINS_SERVER_VIEWS_JOBS
"x-allow-variable": true
}, },
}, },
required: ["serverId", "jobName"] required: ["serverId", "jobName"]

View File

@ -1,5 +1,5 @@
import {ConfigurableNodeDefinition, NodeType, NodeCategory, defineNodeOutputs} from './types'; import {ConfigurableNodeDefinition, NodeType, NodeCategory, defineNodeOutputs} from './types';
import { DataSourceType } from "@/domain/dataSource"; import { DataSourceType } from '@/domain/dataSource';
/** /**
* *
@ -58,27 +58,28 @@ export const NotificationNodeDefinition: ConfigurableNodeDefinition = {
// description: "选择通知发送的渠道", // description: "选择通知发送的渠道",
// 'x-dataSource': DataSourceType.NOTIFICATION_CHANNEL_TYPES // 'x-dataSource': DataSourceType.NOTIFICATION_CHANNEL_TYPES
// }, // },
notificationChannel: { channelId: {
type: "number", type: "number",
title: "通知渠道", title: "通知渠道",
description: "选择通知发送的渠道", description: "选择通知发送的渠道",
'x-dataSource': DataSourceType.NOTIFICATION_CHANNELS "x-dataSource": DataSourceType.NOTIFICATION_CHANNELS,
"x-allow-variable": true
}, },
title: { title: {
type: "string", type: "string",
title: "通知标题", title: "通知标题",
description: "通知消息的标题", description: "通知消息的标题",
default: "工作流通知" default: "${notification.title}"
}, },
content: { content: {
type: "string", type: "string",
title: "通知内容", title: "通知内容",
description: "通知消息的正文内容,支持变量表达式", description: "通知消息的正文内容,支持变量表达式",
format: "textarea", format: "textarea",
default: "" default: "${notification.context}"
} }
}, },
required: ["notificationChannel", "title", "content"] required: ["channelId", "title", "content"]
}, },
outputs: defineNodeOutputs() outputs: defineNodeOutputs()
}; };

View File

@ -67,6 +67,7 @@ export interface JSONSchema {
'x-component'?: string; 'x-component'?: string;
'x-component-props'?: Record<string, any>; 'x-component-props'?: Record<string, any>;
'x-dataSource'?: string; 'x-dataSource'?: string;
'x-allow-variable'?: boolean; // ✨ 是否允许变量输入(用于 enum/dataSource 字段)
[key: string]: any; // 允许扩展属性 [key: string]: any; // 允许扩展属性
} }

View File

@ -62,15 +62,25 @@ export const convertJsonSchemaToZod = (jsonSchema: JSONSchema): z.ZodObject<any>
} }
case 'number': case 'number':
case 'integer': case 'integer': {
fieldSchema = prop.type === 'integer' ? z.number().int() : z.number(); let numberSchema: z.ZodNumber = prop.type === 'integer' ? z.number().int() : z.number();
if (prop.minimum !== undefined) { if (prop.minimum !== undefined) {
fieldSchema = (fieldSchema as z.ZodNumber).min(prop.minimum, `${prop.title || key}不能小于${prop.minimum}`); numberSchema = numberSchema.min(prop.minimum, `${prop.title || key}不能小于${prop.minimum}`);
} }
if (prop.maximum !== undefined) { if (prop.maximum !== undefined) {
fieldSchema = (fieldSchema as z.ZodNumber).max(prop.maximum, `${prop.title || key}不能大于${prop.maximum}`); numberSchema = numberSchema.max(prop.maximum, `${prop.title || key}不能大于${prop.maximum}`);
}
// ✅ 如果允许变量输入,支持 string | number 联合类型
if (prop['x-allow-variable']) {
// 允许变量表达式(字符串)或数字
fieldSchema = z.union([numberSchema, z.string()]);
} else {
fieldSchema = numberSchema;
} }
break; break;
}
case 'boolean': case 'boolean':
fieldSchema = z.boolean(); fieldSchema = z.boolean();