From 70f61292f7748ef8fa625a2a09e4be11f8bbacb9 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Wed, 22 Oct 2025 14:15:21 +0800 Subject: [PATCH] 1 --- .../src/components/VariableInput/index.tsx | 221 ++++++++++++++---- .../Design/components/EdgeConfigModal.tsx | 10 +- .../Design/components/NodeConfigModal.tsx | 6 +- 3 files changed, 181 insertions(+), 56 deletions(-) diff --git a/frontend/src/components/VariableInput/index.tsx b/frontend/src/components/VariableInput/index.tsx index 0fd0b354..1595f0bb 100644 --- a/frontend/src/components/VariableInput/index.tsx +++ b/frontend/src/components/VariableInput/index.tsx @@ -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 = ({ }) => { 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(null); const containerRef = useRef(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(null); // 收集可用变量 const allVariables = useMemo(() => { @@ -54,6 +44,96 @@ const VariableInput: React.FC = ({ 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) => { const newValue = e.target.value; @@ -69,10 +149,37 @@ const VariableInput: React.FC = ({ // 处理键盘事件 const handleKeyDown = (e: React.KeyboardEvent) => { - // 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 = ({ ); } + let globalIndex = 0; // 全局索引,用于跟踪所有变量 + return (
{Array.from(groupedVariables.entries()).map(([nodeName, variables]) => ( @@ -150,16 +259,25 @@ const VariableInput: React.FC = ({ 📦 {nodeName}
- {variables.map((variable) => ( -
handleSelectVariable(variable.nodeName, variable.fieldName)} - > - {variable.fieldName} - {variable.fieldType} -
- ))} + {variables.map((variable) => { + const currentIndex = globalIndex++; + const isSelected = currentIndex === selectedIndex; + + return ( +
handleSelectVariable(variable.nodeName, variable.fieldName)} + onMouseEnter={() => setSelectedIndex(currentIndex)} + ref={isSelected ? (el) => el?.scrollIntoView({ block: 'nearest' }) : undefined} + > + {variable.fieldName} + {variable.fieldType} +
+ ); + })}
))} @@ -167,30 +285,37 @@ const VariableInput: React.FC = ({ ); }; + // 渲染下拉列表(使用 Portal 渲染到 body) + const renderDropdown = () => { + if (!showSuggestions) return null; + + return createPortal( +
+
+ {renderSuggestions()} +
+ 💡 输入 ${'{'} 触发变量提示, + 使用 ↑↓ 选择, + Enter 确认, + Esc 关闭 +
+
+
, + document.body + ); + }; + return (
{/* 输入框 */} {renderInput()} - {/* 变量提示弹窗 - 绝对定位,使用更高的 z-index */} - {showSuggestions && ( -
-
- {renderSuggestions()} -
- 💡 输入 ${'{'} 触发变量提示, - 按 Esc 关闭 -
-
-
- )} + {/* 变量提示弹窗 - 使用 Portal 渲染到 body */} + {renderDropdown()}
); }; diff --git a/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx b/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx index 6bfac3ba..f1c23e1d 100644 --- a/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx +++ b/frontend/src/pages/Workflow/Design/components/EdgeConfigModal.tsx @@ -75,7 +75,7 @@ const EdgeConfigModal: React.FC = ({ // 当 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 = ({ 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 = ({ onChange={field.onChange} allNodes={allNodes} allEdges={allEdges} - currentNodeId={edge?.source || ''} + currentNodeId={edge?.target || ''} variant="textarea" placeholder="请输入条件表达式,如:${Jenkins构建.buildStatus} == 'SUCCESS'" rows={4} diff --git a/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx b/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx index 60da4b6d..601fc214 100644 --- a/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx +++ b/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx @@ -442,15 +442,15 @@ const NodeConfigModal: React.FC = ({ )} - {/* 输出能力 - 只读展示 */} + {/* 输出 - 只读展示 */} {isConfigurableNode(nodeDefinition) && nodeDefinition.outputs && nodeDefinition.outputs.length > 0 && ( - 输出能力 + 输出
- 💡 此节点执行后会产生以下数据,下游节点可以通过 {'${upstream.字段名}'} 引用这些数据 + 💡 此节点执行后会产生以下数据,可以通过 {'${节点名.字段名}'} 引用这些数据
{nodeDefinition.outputs.map((output) => (