1
This commit is contained in:
parent
80ad106934
commit
70f61292f7
@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import type { VariableInputProps } from './types';
|
import type { VariableInputProps } from './types';
|
||||||
@ -22,22 +23,11 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
const [triggerInfo, setTriggerInfo] = useState({ shouldShow: false, startPos: -1, searchText: '' });
|
const [triggerInfo, setTriggerInfo] = useState({ shouldShow: false, startPos: -1, searchText: '' });
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0); // 当前选中的变量索引
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState<'bottom' | 'top'>('bottom'); // 下拉框位置
|
||||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const dropdownRef = 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(() => {
|
const allVariables = useMemo(() => {
|
||||||
@ -54,6 +44,96 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
return groupVariablesByNode(filteredVariables);
|
return groupVariablesByNode(filteredVariables);
|
||||||
}, [filteredVariables]);
|
}, [filteredVariables]);
|
||||||
|
|
||||||
|
// 计算下拉框的位置
|
||||||
|
const getDropdownStyle = useCallback(() => {
|
||||||
|
if (!inputRef.current) return {};
|
||||||
|
|
||||||
|
const inputRect = inputRef.current.getBoundingClientRect();
|
||||||
|
const gap = 8; // 间距
|
||||||
|
|
||||||
|
if (dropdownPosition === 'top') {
|
||||||
|
// 显示在上方
|
||||||
|
return {
|
||||||
|
bottom: window.innerHeight - inputRect.top + window.scrollY + gap,
|
||||||
|
left: inputRect.left + window.scrollX,
|
||||||
|
width: inputRect.width,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// 显示在下方
|
||||||
|
return {
|
||||||
|
top: inputRect.bottom + window.scrollY + gap,
|
||||||
|
left: inputRect.left + window.scrollX,
|
||||||
|
width: inputRect.width,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [dropdownPosition]);
|
||||||
|
|
||||||
|
// 点击外部关闭建议框
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
|
||||||
|
// 检查是否点击了输入框或下拉列表
|
||||||
|
const isClickInside =
|
||||||
|
(containerRef.current && containerRef.current.contains(target)) ||
|
||||||
|
(dropdownRef.current && dropdownRef.current.contains(target));
|
||||||
|
|
||||||
|
if (!isClickInside) {
|
||||||
|
setShowSuggestions(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showSuggestions]);
|
||||||
|
|
||||||
|
// 计算下拉框位置(上方或下方)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSuggestions || !inputRef.current || !dropdownRef.current) return;
|
||||||
|
|
||||||
|
const inputRect = inputRef.current.getBoundingClientRect();
|
||||||
|
const dropdownHeight = 320; // 估算的下拉框高度
|
||||||
|
const spaceBelow = window.innerHeight - inputRect.bottom;
|
||||||
|
const spaceAbove = inputRect.top;
|
||||||
|
|
||||||
|
// 如果下方空间不足,且上方空间更多,则显示在上方
|
||||||
|
if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) {
|
||||||
|
setDropdownPosition('top');
|
||||||
|
} else {
|
||||||
|
setDropdownPosition('bottom');
|
||||||
|
}
|
||||||
|
}, [showSuggestions]);
|
||||||
|
|
||||||
|
// 当显示建议时,重置选中索引
|
||||||
|
useEffect(() => {
|
||||||
|
if (showSuggestions) {
|
||||||
|
setSelectedIndex(0);
|
||||||
|
}
|
||||||
|
}, [showSuggestions]);
|
||||||
|
|
||||||
|
// 监听滚动和窗口大小变化,更新下拉框位置
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
|
const handlePositionUpdate = () => {
|
||||||
|
// 强制重新渲染以更新位置
|
||||||
|
if (dropdownRef.current) {
|
||||||
|
const style = getDropdownStyle();
|
||||||
|
Object.assign(dropdownRef.current.style, style);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handlePositionUpdate, true);
|
||||||
|
window.addEventListener('resize', handlePositionUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handlePositionUpdate, true);
|
||||||
|
window.removeEventListener('resize', handlePositionUpdate);
|
||||||
|
};
|
||||||
|
}, [showSuggestions, getDropdownStyle]);
|
||||||
|
|
||||||
// 处理输入变化
|
// 处理输入变化
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
@ -69,10 +149,37 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
|
|
||||||
// 处理键盘事件
|
// 处理键盘事件
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
if (!showSuggestions) return;
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
// Esc 键关闭建议框
|
// Esc 键关闭建议框
|
||||||
if (e.key === 'Escape' && showSuggestions) {
|
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowDown':
|
||||||
|
// ↓ 键向下选择
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev =>
|
||||||
|
prev < filteredVariables.length - 1 ? prev + 1 : prev
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ArrowUp':
|
||||||
|
// ↑ 键向上选择
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedIndex(prev => prev > 0 ? prev - 1 : prev);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'Enter':
|
||||||
|
// Enter 键选中当前变量
|
||||||
|
e.preventDefault();
|
||||||
|
if (filteredVariables[selectedIndex]) {
|
||||||
|
const variable = filteredVariables[selectedIndex];
|
||||||
|
handleSelectVariable(variable.nodeName, variable.fieldName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -142,6 +249,8 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let globalIndex = 0; // 全局索引,用于跟踪所有变量
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-80 overflow-y-auto">
|
<div className="max-h-80 overflow-y-auto">
|
||||||
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => (
|
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => (
|
||||||
@ -150,16 +259,25 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
📦 {nodeName}
|
📦 {nodeName}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{variables.map((variable) => (
|
{variables.map((variable) => {
|
||||||
|
const currentIndex = globalIndex++;
|
||||||
|
const isSelected = currentIndex === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${variable.nodeId}.${variable.fieldName}`}
|
key={`${variable.nodeId}.${variable.fieldName}`}
|
||||||
className="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-accent rounded-sm transition-colors"
|
className={`flex items-center justify-between px-3 py-2 cursor-pointer rounded-sm transition-colors ${
|
||||||
|
isSelected ? 'bg-accent text-accent-foreground' : 'hover:bg-accent'
|
||||||
|
}`}
|
||||||
onClick={() => handleSelectVariable(variable.nodeName, variable.fieldName)}
|
onClick={() => handleSelectVariable(variable.nodeName, variable.fieldName)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||||
|
ref={isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
||||||
>
|
>
|
||||||
<span className="font-mono text-sm">{variable.fieldName}</span>
|
<span className="font-mono text-sm">{variable.fieldName}</span>
|
||||||
<span className="ml-auto text-xs text-muted-foreground">{variable.fieldType}</span>
|
<span className="ml-auto text-xs text-muted-foreground">{variable.fieldType}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -167,30 +285,37 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 渲染下拉列表(使用 Portal 渲染到 body)
|
||||||
|
const renderDropdown = () => {
|
||||||
|
if (!showSuggestions) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className="fixed z-[99999]"
|
||||||
|
style={getDropdownStyle()}
|
||||||
|
>
|
||||||
|
<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">↑↓</kbd> 选择,
|
||||||
|
<kbd className="px-1 py-0.5 bg-background rounded border">Enter</kbd> 确认,
|
||||||
|
<kbd className="px-1 py-0.5 bg-background rounded border">Esc</kbd> 关闭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="relative">
|
<div ref={containerRef} className="relative">
|
||||||
{/* 输入框 */}
|
{/* 输入框 */}
|
||||||
{renderInput()}
|
{renderInput()}
|
||||||
|
|
||||||
{/* 变量提示弹窗 - 绝对定位,使用更高的 z-index */}
|
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */}
|
||||||
{showSuggestions && (
|
{renderDropdown()}
|
||||||
<div
|
|
||||||
className="fixed mt-2 z-[9999]"
|
|
||||||
style={{
|
|
||||||
top: inputRef.current ? inputRef.current.getBoundingClientRect().bottom + window.scrollY + 8 : 0,
|
|
||||||
left: inputRef.current ? inputRef.current.getBoundingClientRect().left + window.scrollX : 0,
|
|
||||||
width: inputRef.current ? inputRef.current.getBoundingClientRect().width : 'auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -75,7 +75,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
|||||||
|
|
||||||
// 当 edge 变化时,更新表单值
|
// 当 edge 变化时,更新表单值
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && edge) {
|
if (visible && edge && allNodes.length > 0) {
|
||||||
const condition = edge.data?.condition;
|
const condition = edge.data?.condition;
|
||||||
|
|
||||||
// 转换 expression 中的 UUID 为显示名称
|
// 转换 expression 中的 UUID 为显示名称
|
||||||
@ -91,10 +91,10 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
|||||||
form.reset(values);
|
form.reset(values);
|
||||||
setConditionType(values.type);
|
setConditionType(values.type);
|
||||||
}
|
}
|
||||||
// 只在 visible 或 edge.id 改变时重置表单
|
// visible, edge.id, 和 allNodes.length 改变时重置表单
|
||||||
// allNodes 改变不应该触发重置(用户正在编辑时)
|
// 使用 allNodes.length 而不是 allNodes,避免引用变化导致的频繁重置
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [visible, edge?.id]);
|
}, [visible, edge?.id, allNodes.length]);
|
||||||
|
|
||||||
const handleSubmit = (values: EdgeConditionFormValues) => {
|
const handleSubmit = (values: EdgeConditionFormValues) => {
|
||||||
if (!edge) return;
|
if (!edge) return;
|
||||||
@ -201,7 +201,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
|||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
allNodes={allNodes}
|
allNodes={allNodes}
|
||||||
allEdges={allEdges}
|
allEdges={allEdges}
|
||||||
currentNodeId={edge?.source || ''}
|
currentNodeId={edge?.target || ''}
|
||||||
variant="textarea"
|
variant="textarea"
|
||||||
placeholder="请输入条件表达式,如:${Jenkins构建.buildStatus} == 'SUCCESS'"
|
placeholder="请输入条件表达式,如:${Jenkins构建.buildStatus} == 'SUCCESS'"
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|||||||
@ -442,15 +442,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 输出能力 - 只读展示 */}
|
{/* 输出 - 只读展示 */}
|
||||||
{isConfigurableNode(nodeDefinition) && nodeDefinition.outputs && nodeDefinition.outputs.length > 0 && (
|
{isConfigurableNode(nodeDefinition) && nodeDefinition.outputs && nodeDefinition.outputs.length > 0 && (
|
||||||
<AccordionItem value="output" className="border-b">
|
<AccordionItem value="output" className="border-b">
|
||||||
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
||||||
输出能力
|
输出
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="px-1">
|
<AccordionContent className="px-1">
|
||||||
<div className="text-sm text-muted-foreground mb-4 p-3 bg-muted/50 rounded-md">
|
<div className="text-sm text-muted-foreground mb-4 p-3 bg-muted/50 rounded-md">
|
||||||
💡 此节点执行后会产生以下数据,下游节点可以通过 <code className="px-1 py-0.5 bg-background rounded">{'${upstream.字段名}'}</code> 引用这些数据
|
💡 此节点执行后会产生以下数据,可以通过 <code className="px-1 py-0.5 bg-background rounded">{'${节点名.字段名}'}</code> 引用这些数据
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{nodeDefinition.outputs.map((output) => (
|
{nodeDefinition.outputs.map((output) => (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user