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 { Textarea } from '@/components/ui/textarea';
|
||||
import type { VariableInputProps } from './types';
|
||||
@ -22,22 +23,11 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
}) => {
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
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 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 dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 收集可用变量
|
||||
const allVariables = useMemo(() => {
|
||||
@ -54,6 +44,96 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
return groupVariablesByNode(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 newValue = e.target.value;
|
||||
@ -69,10 +149,37 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Esc 键关闭建议框
|
||||
if (e.key === 'Escape' && showSuggestions) {
|
||||
setShowSuggestions(false);
|
||||
e.preventDefault();
|
||||
if (!showSuggestions) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
// Esc 键关闭建议框
|
||||
setShowSuggestions(false);
|
||||
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 (
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => (
|
||||
@ -150,16 +259,25 @@ const VariableInput: React.FC<VariableInputProps> = ({
|
||||
📦 {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={() => 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>
|
||||
))}
|
||||
{variables.map((variable) => {
|
||||
const currentIndex = globalIndex++;
|
||||
const isSelected = currentIndex === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${variable.nodeId}.${variable.fieldName}`}
|
||||
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)}
|
||||
onMouseEnter={() => setSelectedIndex(currentIndex)}
|
||||
ref={isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined}
|
||||
>
|
||||
<span className="font-mono text-sm">{variable.fieldName}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">{variable.fieldType}</span>
|
||||
</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 (
|
||||
<div ref={containerRef} className="relative">
|
||||
{/* 输入框 */}
|
||||
{renderInput()}
|
||||
|
||||
{/* 变量提示弹窗 - 绝对定位,使用更高的 z-index */}
|
||||
{showSuggestions && (
|
||||
<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>
|
||||
)}
|
||||
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */}
|
||||
{renderDropdown()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -75,7 +75,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
|
||||
// 当 edge 变化时,更新表单值
|
||||
useEffect(() => {
|
||||
if (visible && edge) {
|
||||
if (visible && edge && allNodes.length > 0) {
|
||||
const condition = edge.data?.condition;
|
||||
|
||||
// 转换 expression 中的 UUID 为显示名称
|
||||
@ -91,10 +91,10 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
form.reset(values);
|
||||
setConditionType(values.type);
|
||||
}
|
||||
// 只在 visible 或 edge.id 改变时重置表单
|
||||
// allNodes 改变不应该触发重置(用户正在编辑时)
|
||||
// visible, edge.id, 和 allNodes.length 改变时重置表单
|
||||
// 使用 allNodes.length 而不是 allNodes,避免引用变化导致的频繁重置
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [visible, edge?.id]);
|
||||
}, [visible, edge?.id, allNodes.length]);
|
||||
|
||||
const handleSubmit = (values: EdgeConditionFormValues) => {
|
||||
if (!edge) return;
|
||||
@ -201,7 +201,7 @@ const EdgeConfigModal: React.FC<EdgeConfigModalProps> = ({
|
||||
onChange={field.onChange}
|
||||
allNodes={allNodes}
|
||||
allEdges={allEdges}
|
||||
currentNodeId={edge?.source || ''}
|
||||
currentNodeId={edge?.target || ''}
|
||||
variant="textarea"
|
||||
placeholder="请输入条件表达式,如:${Jenkins构建.buildStatus} == 'SUCCESS'"
|
||||
rows={4}
|
||||
|
||||
@ -442,15 +442,15 @@ const NodeConfigModal: React.FC<NodeConfigModalProps> = ({
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
{/* 输出能力 - 只读展示 */}
|
||||
{/* 输出 - 只读展示 */}
|
||||
{isConfigurableNode(nodeDefinition) && nodeDefinition.outputs && nodeDefinition.outputs.length > 0 && (
|
||||
<AccordionItem value="output" className="border-b">
|
||||
<AccordionTrigger className="text-base font-semibold flex-row-reverse justify-end gap-2 hover:no-underline">
|
||||
输出能力
|
||||
输出
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1">
|
||||
<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 className="space-y-3">
|
||||
{nodeDefinition.outputs.map((output) => (
|
||||
|
||||
Loading…
Reference in New Issue
Block a user