1
This commit is contained in:
parent
56338dc3a8
commit
313307a19d
256
frontend/src/components/VariableInput/index.tsx
Normal file
256
frontend/src/components/VariableInput/index.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import type { VariableInputProps } from './types';
|
||||
import { detectTrigger, insertVariable, filterVariables } from './utils';
|
||||
import { collectNodeVariables, groupVariablesByNode } from '@/utils/workflow/collectNodeVariables';
|
||||
|
||||
/**
|
||||
* 高亮渲染 ${} 变量
|
||||
*/
|
||||
const highlightVariables = (text: string): React.ReactNode[] => {
|
||||
const regex = /\$\{([^}]+)\}/g;
|
||||
const parts: React.ReactNode[] = [];
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
// 添加变量前的文本
|
||||
if (match.index > lastIndex) {
|
||||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// 添加高亮的变量
|
||||
parts.push(
|
||||
<span
|
||||
key={match.index}
|
||||
className="inline-block px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono text-sm border border-blue-200 dark:border-blue-800"
|
||||
>
|
||||
${`{${match[1]}}`}
|
||||
</span>
|
||||
);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(text.substring(lastIndex));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : [text];
|
||||
};
|
||||
|
||||
/**
|
||||
* 变量输入组件
|
||||
* 支持在输入 ${ 时自动提示可用变量
|
||||
*/
|
||||
const VariableInput: React.FC<VariableInputProps> = ({
|
||||
value = '',
|
||||
onChange,
|
||||
allNodes,
|
||||
allEdges,
|
||||
currentNodeId,
|
||||
variant = 'input',
|
||||
placeholder,
|
||||
disabled = false,
|
||||
rows = 3,
|
||||
}) => {
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [triggerInfo, setTriggerInfo] = useState({ shouldShow: false, startPos: -1, searchText: '' });
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 点击外部关闭建议框
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [showSuggestions]);
|
||||
|
||||
// 收集可用变量
|
||||
const allVariables = useMemo(() => {
|
||||
return collectNodeVariables(currentNodeId, allNodes, allEdges);
|
||||
}, [currentNodeId, allNodes, allEdges]);
|
||||
|
||||
// 根据搜索文本过滤变量
|
||||
const filteredVariables = useMemo(() => {
|
||||
return filterVariables(allVariables, triggerInfo.searchText);
|
||||
}, [allVariables, triggerInfo.searchText]);
|
||||
|
||||
// 按节点分组
|
||||
const groupedVariables = useMemo(() => {
|
||||
return groupVariablesByNode(filteredVariables);
|
||||
}, [filteredVariables]);
|
||||
|
||||
// 处理输入变化
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const newValue = e.target.value;
|
||||
const cursorPos = e.target.selectionStart || 0;
|
||||
|
||||
onChange(newValue);
|
||||
|
||||
// 检测是否应该触发变量建议
|
||||
const trigger = detectTrigger(newValue, cursorPos);
|
||||
setTriggerInfo(trigger);
|
||||
setShowSuggestions(trigger.shouldShow && !disabled);
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Esc 键关闭建议框
|
||||
if (e.key === 'Escape' && showSuggestions) {
|
||||
setShowSuggestions(false);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// 插入变量
|
||||
const handleSelectVariable = (nodeName: string, fieldName: string) => {
|
||||
console.log('🎯 handleSelectVariable 被调用:', { nodeName, fieldName, value, triggerInfo });
|
||||
|
||||
if (!inputRef.current) {
|
||||
console.error('❌ inputRef.current 不存在');
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorPos = inputRef.current.selectionStart || 0;
|
||||
console.log('📍 当前光标位置:', cursorPos);
|
||||
|
||||
// 使用工具函数插入变量
|
||||
const { newText, newCursorPos } = insertVariable(
|
||||
value,
|
||||
cursorPos,
|
||||
triggerInfo.startPos,
|
||||
nodeName,
|
||||
fieldName
|
||||
);
|
||||
|
||||
console.log('✅ 插入结果:', { newText, newCursorPos });
|
||||
|
||||
// 更新值
|
||||
onChange(newText);
|
||||
|
||||
// 关闭建议框
|
||||
setShowSuggestions(false);
|
||||
|
||||
// 恢复焦点并设置光标位置
|
||||
setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
|
||||
console.log('✅ 焦点已恢复,光标已设置');
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
// 渲染输入框
|
||||
const renderInput = () => {
|
||||
const commonProps = {
|
||||
ref: inputRef as any,
|
||||
value,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
placeholder,
|
||||
disabled,
|
||||
};
|
||||
|
||||
if (variant === 'input') {
|
||||
return <Input {...commonProps} />;
|
||||
}
|
||||
|
||||
return <Textarea {...commonProps} rows={rows} />;
|
||||
};
|
||||
|
||||
// 渲染变量提示
|
||||
const renderSuggestions = () => {
|
||||
if (allVariables.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
当前没有可用变量(没有前序节点或前序节点无输出)
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filteredVariables.length === 0) {
|
||||
return (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||
未找到匹配的变量
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => (
|
||||
<div key={nodeName} className="py-2">
|
||||
<div className="px-3 py-1 text-xs font-semibold text-muted-foreground">
|
||||
📦 {nodeName}
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{variables.map((variable) => (
|
||||
<div
|
||||
key={`${variable.nodeId}.${variable.fieldName}`}
|
||||
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-accent rounded-sm transition-colors"
|
||||
onClick={() => {
|
||||
console.log('点击变量:', variable.nodeName, variable.fieldName);
|
||||
handleSelectVariable(variable.nodeName, variable.fieldName);
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-sm">{variable.fieldName}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{variable.fieldType}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 检测是否有变量需要高亮
|
||||
const hasVariables = value && /\$\{[^}]+\}/.test(value);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative space-y-2">
|
||||
{/* 输入框 */}
|
||||
{renderInput()}
|
||||
|
||||
{/* 变量高亮预览 */}
|
||||
{hasVariables && !showSuggestions && (
|
||||
<div className="p-2 bg-muted/30 rounded-md border border-border text-sm">
|
||||
<div className="text-xs text-muted-foreground mb-1">变量预览:</div>
|
||||
<div className="break-words leading-relaxed">
|
||||
{highlightVariables(value)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 变量提示弹窗 - 绝对定位 */}
|
||||
{showSuggestions && (
|
||||
<div
|
||||
className="absolute top-full left-0 w-full mt-2 z-50"
|
||||
>
|
||||
<div className="w-full max-w-[500px] bg-popover text-popover-foreground rounded-md border shadow-lg">
|
||||
{renderSuggestions()}
|
||||
<div className="px-3 py-2 border-t text-xs text-muted-foreground bg-muted/30">
|
||||
💡 输入 <code className="px-1 py-0.5 bg-background rounded">${'{'}</code> 触发变量提示,
|
||||
按 <kbd className="px-1 py-0.5 bg-background rounded border">Esc</kbd> 关闭
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VariableInput;
|
||||
|
||||
48
frontend/src/components/VariableInput/types.ts
Normal file
48
frontend/src/components/VariableInput/types.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types';
|
||||
|
||||
/**
|
||||
* 变量输入组件的 Props
|
||||
*/
|
||||
export interface VariableInputProps {
|
||||
/** 输入值(显示名称格式)*/
|
||||
value: string;
|
||||
|
||||
/** 值变化回调 */
|
||||
onChange: (value: string) => void;
|
||||
|
||||
/** 所有节点(用于收集变量)*/
|
||||
allNodes: FlowNode[];
|
||||
|
||||
/** 所有连接边(用于确定前序节点)*/
|
||||
allEdges: FlowEdge[];
|
||||
|
||||
/** 当前节点ID(用于过滤前序节点)*/
|
||||
currentNodeId: string;
|
||||
|
||||
/** 渲染类型 */
|
||||
variant?: 'input' | 'textarea';
|
||||
|
||||
/** 占位符 */
|
||||
placeholder?: string;
|
||||
|
||||
/** 是否禁用 */
|
||||
disabled?: boolean;
|
||||
|
||||
/** Textarea 行数 */
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发信息
|
||||
*/
|
||||
export interface TriggerInfo {
|
||||
/** 是否应该显示变量提示 */
|
||||
shouldShow: boolean;
|
||||
|
||||
/** ${ 的起始位置 */
|
||||
startPos: number;
|
||||
|
||||
/** 搜索文本(${ 之后的文本)*/
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
95
frontend/src/components/VariableInput/utils.ts
Normal file
95
frontend/src/components/VariableInput/utils.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import type { TriggerInfo } from './types';
|
||||
import type { NodeVariable } from '@/utils/workflow/collectNodeVariables';
|
||||
|
||||
/**
|
||||
* 检测是否应该触发变量建议
|
||||
*
|
||||
* @param text 完整文本
|
||||
* @param cursorPos 光标位置
|
||||
* @returns 触发信息
|
||||
*/
|
||||
export const detectTrigger = (text: string, cursorPos: number): TriggerInfo => {
|
||||
// 获取光标前的文本
|
||||
const beforeCursor = text.substring(0, cursorPos);
|
||||
|
||||
// 找到最后一个 ${
|
||||
const lastOpenIndex = beforeCursor.lastIndexOf('${');
|
||||
|
||||
// 如果没有找到 ${,或者找到的位置在很早之前(超过100个字符),不触发
|
||||
if (lastOpenIndex === -1 || (cursorPos - lastOpenIndex) > 100) {
|
||||
return { shouldShow: false, startPos: -1, searchText: '' };
|
||||
}
|
||||
|
||||
// 获取 ${ 之后到光标的文本
|
||||
const textAfterOpen = beforeCursor.substring(lastOpenIndex + 2);
|
||||
|
||||
// 如果这段文本中已经有 },说明表达式已经闭合,不触发
|
||||
if (textAfterOpen.includes('}')) {
|
||||
return { shouldShow: false, startPos: -1, searchText: '' };
|
||||
}
|
||||
|
||||
// 返回触发信息
|
||||
return {
|
||||
shouldShow: true,
|
||||
startPos: lastOpenIndex,
|
||||
searchText: textAfterOpen.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 插入变量到文本中
|
||||
*
|
||||
* @param originalText 原始文本
|
||||
* @param cursorPos 光标位置
|
||||
* @param triggerStartPos ${ 的起始位置
|
||||
* @param nodeName 节点名称
|
||||
* @param fieldName 字段名
|
||||
* @returns 新的文本和光标位置
|
||||
*/
|
||||
export const insertVariable = (
|
||||
originalText: string,
|
||||
cursorPos: number,
|
||||
triggerStartPos: number,
|
||||
nodeName: string,
|
||||
fieldName: string
|
||||
): { newText: string; newCursorPos: number } => {
|
||||
// ${ 之前的文本
|
||||
const before = originalText.substring(0, triggerStartPos);
|
||||
|
||||
// 光标之后的文本
|
||||
const after = originalText.substring(cursorPos);
|
||||
|
||||
// 构建新文本:before + ${nodeName.fieldName} + after
|
||||
const variableText = `\${${nodeName}.${fieldName}}`;
|
||||
const newText = `${before}${variableText}${after}`;
|
||||
|
||||
// 计算新的光标位置(在 } 之后)
|
||||
const newCursorPos = before.length + variableText.length;
|
||||
|
||||
return { newText, newCursorPos };
|
||||
};
|
||||
|
||||
/**
|
||||
* 过滤变量列表
|
||||
*
|
||||
* @param variables 所有变量
|
||||
* @param searchText 搜索文本
|
||||
* @returns 过滤后的变量列表
|
||||
*/
|
||||
export const filterVariables = (variables: NodeVariable[], searchText: string): NodeVariable[] => {
|
||||
if (!searchText) {
|
||||
return variables;
|
||||
}
|
||||
|
||||
const lowerSearch = searchText.toLowerCase();
|
||||
|
||||
return variables.filter(v => {
|
||||
// 匹配节点名称或字段名
|
||||
const matchesNodeName = v.nodeName?.toLowerCase().includes(lowerSearch);
|
||||
const matchesFieldName = v.fieldName?.toLowerCase().includes(lowerSearch);
|
||||
const matchesFullText = v.fullText?.toLowerCase().includes(lowerSearch);
|
||||
|
||||
return matchesNodeName || matchesFieldName || matchesFullText;
|
||||
});
|
||||
};
|
||||
|
||||
@ -6,14 +6,17 @@ 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 { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { FlowEdge } from '../types';
|
||||
import type { FlowEdge, FlowNode } from '../types';
|
||||
import { convertToUUID, convertToDisplayName } from '@/utils/workflow/variableConversion';
|
||||
import VariableInput from '@/components/VariableInput';
|
||||
|
||||
interface EdgeConfigModalProps {
|
||||
visible: boolean;
|
||||
edge: FlowEdge | null;
|
||||
allNodes: FlowNode[];
|
||||
allEdges: FlowEdge[];
|
||||
onOk: (edgeId: string, condition: EdgeCondition) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@ -53,6 +56,8 @@ type EdgeConditionFormValues = z.infer<typeof edgeConditionSchema>;
|
||||
const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
visible,
|
||||
edge,
|
||||
allNodes,
|
||||
allEdges,
|
||||
onOk,
|
||||
onCancel
|
||||
}) => {
|
||||
@ -72,22 +77,36 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
useEffect(() => {
|
||||
if (visible && edge) {
|
||||
const condition = edge.data?.condition;
|
||||
|
||||
// 转换 expression 中的 UUID 为显示名称
|
||||
const displayExpression = condition?.expression
|
||||
? convertToDisplayName(condition.expression, allNodes)
|
||||
: '';
|
||||
|
||||
const values = {
|
||||
type: (condition?.type || 'EXPRESSION') as 'EXPRESSION' | 'DEFAULT',
|
||||
expression: condition?.expression || '',
|
||||
expression: displayExpression,
|
||||
priority: condition?.priority || 10,
|
||||
};
|
||||
form.reset(values);
|
||||
setConditionType(values.type);
|
||||
}
|
||||
}, [visible, edge, form]);
|
||||
// 只在 visible 或 edge.id 改变时重置表单
|
||||
// allNodes 改变不应该触发重置(用户正在编辑时)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, edge?.id]);
|
||||
|
||||
const handleSubmit = (values: EdgeConditionFormValues) => {
|
||||
if (!edge) return;
|
||||
|
||||
// 转换 expression 中的显示名称为 UUID
|
||||
const uuidExpression = values.expression
|
||||
? convertToUUID(values.expression, allNodes)
|
||||
: '';
|
||||
|
||||
// 检查表达式是否包含变量引用
|
||||
if (values.type === 'EXPRESSION' && values.expression) {
|
||||
const hasVariable = /\$\{[\w.]+\}/.test(values.expression);
|
||||
if (values.type === 'EXPRESSION' && uuidExpression) {
|
||||
const hasVariable = /\$\{[\w.-]+\}/.test(uuidExpression);
|
||||
if (!hasVariable) {
|
||||
toast({
|
||||
title: '提示',
|
||||
@ -96,7 +115,11 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
onOk(edge.id, values);
|
||||
// 提交 UUID 格式的数据
|
||||
onOk(edge.id, {
|
||||
...values,
|
||||
expression: uuidExpression
|
||||
});
|
||||
handleClose();
|
||||
};
|
||||
|
||||
@ -173,14 +196,19 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
<FormItem>
|
||||
<FormLabel>条件表达式</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="请输入条件表达式,如:${amount} > 1000"
|
||||
rows={4}
|
||||
{...field}
|
||||
<VariableInput
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
allNodes={allNodes}
|
||||
allEdges={allEdges}
|
||||
currentNodeId={edge?.source || ''}
|
||||
variant="textarea"
|
||||
placeholder="请输入条件表达式,如:${Jenkins构建.buildStatus} == 'SUCCESS'"
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
支持使用 {'${变量名}'} 引用流程变量,例如:{'${amount} > 1000'}
|
||||
支持使用 {'${节点名称.字段名}'} 引用前序节点的输出变量,输入 ${'{'} 触发变量提示
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -10,15 +10,19 @@ import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import type { FlowNode, FlowNodeData } from '../types';
|
||||
import type { FlowNode, FlowNodeData, FlowEdge } from '../types';
|
||||
import type { WorkflowNodeDefinition, JSONSchema } from '../nodes/types';
|
||||
import { isConfigurableNode } from '../nodes/types';
|
||||
import { convertJsonSchemaToZod, extractDataSourceTypes } from '../utils/schemaConverter';
|
||||
import { loadDataSource, DataSourceType, type DataSourceOption } from '../utils/dataSourceLoader';
|
||||
import { convertObjectToUUID, convertObjectToDisplayName } from '@/utils/workflow/variableConversion';
|
||||
import VariableInput from '@/components/VariableInput';
|
||||
|
||||
interface NodeConfigModalProps {
|
||||
visible: boolean;
|
||||
node: FlowNode | null;
|
||||
allNodes: FlowNode[];
|
||||
allEdges: FlowEdge[];
|
||||
onCancel: () => void;
|
||||
onOk: (nodeId: string, updatedData: Partial<FlowNodeData>) => void;
|
||||
}
|
||||
@ -26,6 +30,8 @@ interface NodeConfigModalProps {
|
||||
const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
visible,
|
||||
node,
|
||||
allNodes,
|
||||
allEdges,
|
||||
onCancel,
|
||||
onOk
|
||||
}) => {
|
||||
@ -116,14 +122,27 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
nodeCode: nodeDefinition.nodeCode,
|
||||
description: nodeDefinition.description || ''
|
||||
};
|
||||
configForm.reset({ ...defaultConfig, ...(nodeData.configs || {}) });
|
||||
|
||||
// 设置输入映射默认值
|
||||
// 转换 UUID 格式为显示名称格式
|
||||
const displayConfigs = convertObjectToDisplayName(
|
||||
{ ...defaultConfig, ...(nodeData.configs || {}) },
|
||||
allNodes
|
||||
);
|
||||
configForm.reset(displayConfigs);
|
||||
|
||||
// 设置输入映射默认值(也需要转换)
|
||||
if (isConfigurableNode(nodeDefinition)) {
|
||||
inputForm.reset(nodeData.inputMapping || {});
|
||||
const displayInputMapping = convertObjectToDisplayName(
|
||||
nodeData.inputMapping || {},
|
||||
allNodes
|
||||
);
|
||||
inputForm.reset(displayInputMapping);
|
||||
}
|
||||
}
|
||||
}, [visible, node, nodeDefinition, configForm, inputForm]);
|
||||
// 只在 visible 或 node.id 改变时重置表单
|
||||
// allNodes 改变不应该触发重置(用户正在编辑时)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, node?.id]);
|
||||
|
||||
// ✅ 保存配置
|
||||
const handleSave = () => {
|
||||
@ -139,11 +158,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
inputData = inputForm.getValues();
|
||||
}
|
||||
|
||||
// 转换显示名称格式为 UUID 格式
|
||||
const uuidConfigs = convertObjectToUUID(configData as Record<string, any>, allNodes);
|
||||
const uuidInputMapping = convertObjectToUUID(inputData as Record<string, any>, allNodes);
|
||||
|
||||
// 构建更新数据
|
||||
const updatedData: Partial<FlowNodeData> = {
|
||||
label: (configData as any).nodeName || nodeDefinition.nodeName,
|
||||
configs: configData as Record<string, any>,
|
||||
inputMapping: inputData,
|
||||
configs: uuidConfigs,
|
||||
inputMapping: uuidInputMapping,
|
||||
outputs: isConfigurableNode(nodeDefinition) ? nodeDefinition.outputs || [] : []
|
||||
};
|
||||
|
||||
@ -244,25 +267,52 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
|
||||
// 根据类型渲染
|
||||
switch (prop.type) {
|
||||
case 'string':
|
||||
if (prop.format === 'textarea' || key.includes('description')) {
|
||||
case 'string': {
|
||||
// 黑名单字段:不使用变量输入
|
||||
const blacklist = ['nodeName', 'nodeCode', 'description'];
|
||||
const isBlacklisted = blacklist.includes(key);
|
||||
const isPassword = prop.format === 'password';
|
||||
|
||||
// 密码字段或黑名单字段:使用普通输入
|
||||
if (isPassword || isBlacklisted) {
|
||||
if (prop.format === 'textarea' || key.includes('description')) {
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
type={isPassword ? 'password' : 'text'}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 其他字符串字段:使用 VariableInput
|
||||
const isTextarea = prop.format === 'textarea' ||
|
||||
['content', 'body', 'expression', 'script'].includes(key);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
disabled={loading}
|
||||
<VariableInput
|
||||
value={field.value || ''}
|
||||
onChange={field.onChange}
|
||||
allNodes={allNodes}
|
||||
allEdges={allEdges}
|
||||
currentNodeId={node?.id || ''}
|
||||
variant={isTextarea ? 'textarea' : 'input'}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
disabled={loading}
|
||||
rows={3}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
{...field}
|
||||
type={prop.format === 'password' ? 'password' : 'text'}
|
||||
disabled={loading}
|
||||
placeholder={prop.description || `请输入${prop.title || ''}`}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'integer':
|
||||
@ -299,7 +349,7 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [dataSourceCache, loadingDataSources, loading]);
|
||||
}, [dataSourceCache, loadingDataSources, loading, node, allNodes, allEdges]);
|
||||
|
||||
// ✅ 渲染表单字段
|
||||
const renderFormFields = useCallback((
|
||||
|
||||
@ -525,6 +525,8 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
<NodeConfigModal
|
||||
visible={configModalVisible}
|
||||
node={configNode}
|
||||
allNodes={getNodes() as FlowNode[]}
|
||||
allEdges={getEdges() as FlowEdge[]}
|
||||
onCancel={handleCloseConfigModal}
|
||||
onOk={handleNodeConfigUpdate}
|
||||
/>
|
||||
@ -533,6 +535,8 @@ const WorkflowDesignInner: React.FC = () => {
|
||||
<EdgeConfigModal
|
||||
visible={edgeConfigModalVisible}
|
||||
edge={configEdge}
|
||||
allNodes={getNodes() as FlowNode[]}
|
||||
allEdges={getEdges() as FlowEdge[]}
|
||||
onOk={handleEdgeConditionUpdate}
|
||||
onCancel={handleCloseEdgeConfigModal}
|
||||
/>
|
||||
|
||||
120
frontend/src/utils/workflow/collectNodeVariables.ts
Normal file
120
frontend/src/utils/workflow/collectNodeVariables.ts
Normal file
@ -0,0 +1,120 @@
|
||||
import type { FlowNode, FlowEdge } from '@/pages/Workflow/Design/types';
|
||||
import { isConfigurableNode } from '@/pages/Workflow/Design/nodes/types';
|
||||
|
||||
/**
|
||||
* 节点变量定义
|
||||
*/
|
||||
export interface NodeVariable {
|
||||
nodeId: string; // 节点UUID
|
||||
nodeName: string; // 节点显示名称
|
||||
fieldName: string; // 字段名
|
||||
fieldType: string; // 字段类型
|
||||
displayText: string; // 界面显示格式: ${节点名称.字段名}
|
||||
fullText: string; // 完整文本格式: 节点名称.字段名(用于搜索)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前节点的所有前序节点(通过DFS反向遍历)
|
||||
*
|
||||
* @param currentNodeId 当前节点ID
|
||||
* @param allNodes 所有节点列表
|
||||
* @param allEdges 所有连接边列表
|
||||
* @returns 所有前序节点(不包括START节点)
|
||||
*/
|
||||
export const getAllPrecedingNodes = (
|
||||
currentNodeId: string,
|
||||
allNodes: FlowNode[],
|
||||
allEdges: FlowEdge[]
|
||||
): FlowNode[] => {
|
||||
const precedingNodeIds = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
|
||||
// DFS 反向遍历
|
||||
const dfs = (nodeId: string) => {
|
||||
if (visited.has(nodeId)) return;
|
||||
visited.add(nodeId);
|
||||
|
||||
// 找到所有指向当前节点的边
|
||||
const incomingEdges = allEdges.filter(e => e.target === nodeId);
|
||||
|
||||
incomingEdges.forEach(edge => {
|
||||
precedingNodeIds.add(edge.source);
|
||||
dfs(edge.source); // 递归向上遍历
|
||||
});
|
||||
};
|
||||
|
||||
dfs(currentNodeId);
|
||||
|
||||
// 返回节点对象,排除START节点(它没有输出)
|
||||
return allNodes.filter(node =>
|
||||
precedingNodeIds.has(node.id) &&
|
||||
node.data?.nodeType !== 'START_EVENT'
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 收集指定节点的所有可用变量(来自前序节点的输出)
|
||||
*
|
||||
* @param currentNodeId 当前节点ID
|
||||
* @param allNodes 所有节点列表
|
||||
* @param allEdges 所有连接边列表
|
||||
* @returns 变量列表
|
||||
*/
|
||||
export const collectNodeVariables = (
|
||||
currentNodeId: string,
|
||||
allNodes: FlowNode[],
|
||||
allEdges: FlowEdge[]
|
||||
): NodeVariable[] => {
|
||||
const variables: NodeVariable[] = [];
|
||||
|
||||
// 获取所有前序节点
|
||||
const precedingNodes = getAllPrecedingNodes(currentNodeId, allNodes, allEdges);
|
||||
|
||||
// 遍历前序节点,提取输出字段
|
||||
precedingNodes.forEach(node => {
|
||||
const nodeDefinition = node.data?.nodeDefinition;
|
||||
|
||||
// 检查节点是否有输出定义
|
||||
if (!nodeDefinition || !isConfigurableNode(nodeDefinition) || !nodeDefinition.outputs) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取节点显示名称(优先使用用户自定义的label)
|
||||
const nodeName = node.data.label || nodeDefinition.nodeName || 'Unknown';
|
||||
|
||||
// 为每个输出字段创建变量
|
||||
nodeDefinition.outputs.forEach(output => {
|
||||
variables.push({
|
||||
nodeId: node.id,
|
||||
nodeName: nodeName,
|
||||
fieldName: output.name,
|
||||
fieldType: output.type,
|
||||
displayText: `\${${nodeName}.${output.name}}`,
|
||||
fullText: `${nodeName}.${output.name}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return variables;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按节点分组变量
|
||||
*
|
||||
* @param variables 变量列表
|
||||
* @returns 分组后的变量 Map<节点名称, 变量列表>
|
||||
*/
|
||||
export const groupVariablesByNode = (variables: NodeVariable[]): Map<string, NodeVariable[]> => {
|
||||
const groups = new Map<string, NodeVariable[]>();
|
||||
|
||||
variables.forEach(variable => {
|
||||
const key = variable.nodeName;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, []);
|
||||
}
|
||||
groups.get(key)!.push(variable);
|
||||
});
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
156
frontend/src/utils/workflow/variableConversion.ts
Normal file
156
frontend/src/utils/workflow/variableConversion.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import type { FlowNode } from '@/pages/Workflow/Design/types';
|
||||
|
||||
/**
|
||||
* 变量表达式的正则模式
|
||||
* 匹配 ${xxx.yyy} 格式
|
||||
* xxx: 节点名称或UUID
|
||||
* yyy: 字段名
|
||||
*/
|
||||
const VARIABLE_PATTERN = /\$\{([^.}]+)\.([^}]+)\}/g;
|
||||
|
||||
/**
|
||||
* 将显示名称格式转换为UUID格式
|
||||
* ${节点名称.字段名} → ${节点UUID.字段名}
|
||||
*
|
||||
* @param displayText 包含显示名称的文本
|
||||
* @param allNodes 所有节点列表(用于查找UUID)
|
||||
* @returns 转换后的UUID格式文本
|
||||
*
|
||||
* @example
|
||||
* convertToUUID("构建${Jenkins构建.buildStatus}完成", allNodes)
|
||||
* // 返回: "构建${uuid-123.buildStatus}完成"
|
||||
*/
|
||||
export const convertToUUID = (
|
||||
displayText: string,
|
||||
allNodes: FlowNode[]
|
||||
): string => {
|
||||
if (!displayText || typeof displayText !== 'string') {
|
||||
return displayText;
|
||||
}
|
||||
|
||||
return displayText.replace(VARIABLE_PATTERN, (match, nodeNameOrId, fieldName) => {
|
||||
// 尝试通过名称查找节点
|
||||
const nodeByName = allNodes.find(n => {
|
||||
const nodeName = n.data?.label || n.data?.nodeDefinition?.nodeName;
|
||||
return nodeName === nodeNameOrId;
|
||||
});
|
||||
|
||||
if (nodeByName) {
|
||||
// 找到了,替换为UUID
|
||||
return `\${${nodeByName.id}.${fieldName}}`;
|
||||
}
|
||||
|
||||
// 可能已经是UUID格式,或者节点已被删除
|
||||
// 保持原样
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 将UUID格式转换为显示名称格式
|
||||
* ${节点UUID.字段名} → ${节点名称.字段名}
|
||||
*
|
||||
* @param uuidText 包含UUID的文本
|
||||
* @param allNodes 所有节点列表(用于查找显示名称)
|
||||
* @returns 转换后的显示名称格式文本
|
||||
*
|
||||
* @example
|
||||
* convertToDisplayName("构建${uuid-123.buildStatus}完成", allNodes)
|
||||
* // 返回: "构建${Jenkins构建.buildStatus}完成"
|
||||
*/
|
||||
export const convertToDisplayName = (
|
||||
uuidText: string,
|
||||
allNodes: FlowNode[]
|
||||
): string => {
|
||||
if (!uuidText || typeof uuidText !== 'string') {
|
||||
return uuidText;
|
||||
}
|
||||
|
||||
return uuidText.replace(VARIABLE_PATTERN, (match, nodeIdOrName, fieldName) => {
|
||||
// 尝试通过UUID查找节点
|
||||
const nodeById = allNodes.find(n => n.id === nodeIdOrName);
|
||||
|
||||
if (nodeById) {
|
||||
// 找到了,替换为显示名称
|
||||
const nodeName = nodeById.data?.label || nodeById.data?.nodeDefinition?.nodeName || nodeIdOrName;
|
||||
return `\${${nodeName}.${fieldName}}`;
|
||||
}
|
||||
|
||||
// 可能已经是显示名称格式,或者节点已被删除
|
||||
// 保持原样
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换对象中的所有字符串值(从显示名称到UUID)
|
||||
* 递归处理嵌套对象和数组
|
||||
*
|
||||
* @param obj 要转换的对象
|
||||
* @param allNodes 所有节点列表
|
||||
* @returns 转换后的对象
|
||||
*/
|
||||
export const convertObjectToUUID = <T extends Record<string, any>>(
|
||||
obj: T,
|
||||
allNodes: FlowNode[]
|
||||
): T => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const result: any = Array.isArray(obj) ? [] : {};
|
||||
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// 字符串类型:进行转换
|
||||
result[key] = convertToUUID(value, allNodes);
|
||||
} else if (value && typeof value === 'object') {
|
||||
// 对象或数组:递归转换
|
||||
result[key] = convertObjectToUUID(value, allNodes);
|
||||
} else {
|
||||
// 其他类型:保持原样
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
};
|
||||
|
||||
/**
|
||||
* 转换对象中的所有字符串值(从UUID到显示名称)
|
||||
* 递归处理嵌套对象和数组
|
||||
*
|
||||
* @param obj 要转换的对象
|
||||
* @param allNodes 所有节点列表
|
||||
* @returns 转换后的对象
|
||||
*/
|
||||
export const convertObjectToDisplayName = <T extends Record<string, any>>(
|
||||
obj: T,
|
||||
allNodes: FlowNode[]
|
||||
): T => {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const result: any = Array.isArray(obj) ? [] : {};
|
||||
|
||||
for (const key in obj) {
|
||||
const value = obj[key];
|
||||
|
||||
if (typeof value === 'string') {
|
||||
// 字符串类型:进行转换
|
||||
result[key] = convertToDisplayName(value, allNodes);
|
||||
} else if (value && typeof value === 'object') {
|
||||
// 对象或数组:递归转换
|
||||
result[key] = convertObjectToDisplayName(value, allNodes);
|
||||
} else {
|
||||
// 其他类型:保持原样
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user