This commit is contained in:
dengqichen 2025-10-22 14:15:21 +08:00
parent 80ad106934
commit 70f61292f7
3 changed files with 181 additions and 56 deletions

View File

@ -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>
);
};

View File

@ -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}

View File

@ -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) => (