diff --git a/frontend/src/components/VariableInput/HighlightLayer.tsx b/frontend/src/components/VariableInput/HighlightLayer.tsx new file mode 100644 index 00000000..b0cef773 --- /dev/null +++ b/frontend/src/components/VariableInput/HighlightLayer.tsx @@ -0,0 +1,89 @@ +import React, { useMemo } from 'react'; + +interface HighlightLayerProps { + value: string; + className?: string; +} + +/** + * 变量高亮层 + * 将文本中的变量表达式高亮显示 + */ +const HighlightLayer: React.FC = ({ value, className }) => { + // 正则匹配 ${xxx.yyy} 格式的变量 + const VARIABLE_PATTERN = /\$\{([^}]+)\}/g; + + // 解析文本,分离普通文本和变量 + const highlightedContent = useMemo(() => { + if (!value) return null; + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + // 重置正则表达式的 lastIndex + VARIABLE_PATTERN.lastIndex = 0; + + while ((match = VARIABLE_PATTERN.exec(value)) !== null) { + const matchStart = match.index; + const matchEnd = matchStart + match[0].length; + + // 添加变量前的普通文本 + if (matchStart > lastIndex) { + parts.push( + + {value.substring(lastIndex, matchStart)} + + ); + } + + // 添加高亮的变量 + // 关键:不使用 padding/margin/border,只使用背景色和下划线 + // 不改变字体、字重,保持和输入框完全一致 + parts.push( + + {match[0]} + + ); + + lastIndex = matchEnd; + } + + // 添加最后一段普通文本 + if (lastIndex < value.length) { + parts.push( + + {value.substring(lastIndex)} + + ); + } + + return parts.length > 0 ? parts : value; + }, [value]); + + return ( +
+ {highlightedContent} +
+ ); +}; + +export default HighlightLayer; + diff --git a/frontend/src/components/VariableInput/deleteHelper.ts b/frontend/src/components/VariableInput/deleteHelper.ts new file mode 100644 index 00000000..f74987fa --- /dev/null +++ b/frontend/src/components/VariableInput/deleteHelper.ts @@ -0,0 +1,104 @@ +/** + * 变量删除辅助函数 + * 处理变量的整体删除逻辑 + */ + +const VARIABLE_PATTERN = /\$\{([^}]+)\}/g; + +/** + * 查找光标位置所在的变量范围 + * @param text 文本 + * @param cursorPos 光标位置 + * @returns 变量范围 { start, end } 或 null + */ +export const findVariableAtCursor = ( + text: string, + cursorPos: number +): { start: number; end: number } | null => { + // 重置正则表达式 + VARIABLE_PATTERN.lastIndex = 0; + + let match: RegExpExecArray | null; + while ((match = VARIABLE_PATTERN.exec(text)) !== null) { + const start = match.index; + const end = start + match[0].length; + + // 检查光标是否在变量内部或紧邻变量边界 + if (cursorPos > start && cursorPos <= end) { + return { start, end }; + } + } + + return null; +}; + +/** + * 处理 Backspace 键删除 + * @param text 文本 + * @param selectionStart 选区起始位置 + * @param selectionEnd 选区结束位置 + * @returns 新文本和新光标位置,或 null(使用默认行为) + */ +export const handleBackspaceDelete = ( + text: string, + selectionStart: number, + selectionEnd: number +): { newText: string; newCursorPos: number } | null => { + // 如果有选区,使用默认行为 + if (selectionStart !== selectionEnd) { + return null; + } + + // 检查光标前一个字符是否是变量的结束符 '}' + if (selectionStart > 0 && text[selectionStart - 1] === '}') { + // 查找这个 '}' 对应的变量 + const variable = findVariableAtCursor(text, selectionStart); + if (variable) { + // 删除整个变量 + const newText = text.substring(0, variable.start) + text.substring(variable.end); + return { + newText, + newCursorPos: variable.start + }; + } + } + + // 使用默认行为 + return null; +}; + +/** + * 处理 Delete 键删除 + * @param text 文本 + * @param selectionStart 选区起始位置 + * @param selectionEnd 选区结束位置 + * @returns 新文本和新光标位置,或 null(使用默认行为) + */ +export const handleDeleteKey = ( + text: string, + selectionStart: number, + selectionEnd: number +): { newText: string; newCursorPos: number } | null => { + // 如果有选区,使用默认行为 + if (selectionStart !== selectionEnd) { + return null; + } + + // 检查光标后一个字符是否是变量的开始符 '$' + if (selectionStart < text.length && text[selectionStart] === '$' && text[selectionStart + 1] === '{') { + // 查找这个变量 + const variable = findVariableAtCursor(text, selectionStart + 1); + if (variable) { + // 删除整个变量 + const newText = text.substring(0, variable.start) + text.substring(variable.end); + return { + newText, + newCursorPos: variable.start + }; + } + } + + // 使用默认行为 + return null; +}; + diff --git a/frontend/src/components/VariableInput/index.tsx b/frontend/src/components/VariableInput/index.tsx index 1595f0bb..fe9f0319 100644 --- a/frontend/src/components/VariableInput/index.tsx +++ b/frontend/src/components/VariableInput/index.tsx @@ -5,6 +5,8 @@ import { Textarea } from '@/components/ui/textarea'; import type { VariableInputProps } from './types'; import { detectTrigger, insertVariable, filterVariables } from './utils'; import { collectNodeVariables, groupVariablesByNode } from '@/utils/workflow/collectNodeVariables'; +import HighlightLayer from './HighlightLayer'; +import { handleBackspaceDelete, handleDeleteKey } from './deleteHelper'; /** * 变量输入组件 @@ -28,6 +30,7 @@ const VariableInput: React.FC = ({ const inputRef = useRef(null); const containerRef = useRef(null); const dropdownRef = useRef(null); + const highlightRef = useRef(null); // 收集可用变量 const allVariables = useMemo(() => { @@ -113,6 +116,63 @@ const VariableInput: React.FC = ({ } }, [showSuggestions]); + // 同步输入框和高亮层的样式 + useEffect(() => { + if (!inputRef.current || !highlightRef.current) return; + + const inputElement = inputRef.current; + const highlightElement = highlightRef.current; + + const syncStyles = () => { + if (!inputElement || !highlightElement) return; + + const inputStyles = window.getComputedStyle(inputElement); + + // 复制所有影响文本定位的样式(精确同步) + highlightElement.style.paddingTop = inputStyles.paddingTop; + highlightElement.style.paddingRight = inputStyles.paddingRight; + highlightElement.style.paddingBottom = inputStyles.paddingBottom; + highlightElement.style.paddingLeft = inputStyles.paddingLeft; + highlightElement.style.fontSize = inputStyles.fontSize; + highlightElement.style.fontFamily = inputStyles.fontFamily; + highlightElement.style.fontWeight = inputStyles.fontWeight; + highlightElement.style.lineHeight = inputStyles.lineHeight; + highlightElement.style.letterSpacing = inputStyles.letterSpacing; + highlightElement.style.wordSpacing = inputStyles.wordSpacing; + highlightElement.style.whiteSpace = variant === 'textarea' ? 'pre-wrap' : 'nowrap'; + highlightElement.style.wordWrap = 'break-word'; + highlightElement.style.overflowWrap = inputStyles.overflowWrap; + }; + + // 同步滚动位置(仅 textarea) + const syncScroll = () => { + if (!inputElement || !highlightElement || variant !== 'textarea') return; + + highlightElement.scrollTop = inputElement.scrollTop; + highlightElement.scrollLeft = inputElement.scrollLeft; + }; + + // 初始同步(延迟执行,确保 DOM 完全渲染) + syncStyles(); + setTimeout(syncStyles, 0); + + // 监听窗口大小变化(可能影响字体大小) + const resizeObserver = new ResizeObserver(syncStyles); + resizeObserver.observe(inputElement); + + // 监听滚动事件(仅 textarea) + if (variant === 'textarea') { + inputElement.addEventListener('scroll', syncScroll); + } + + return () => { + resizeObserver.disconnect(); + if (variant === 'textarea') { + inputElement.removeEventListener('scroll', syncScroll); + } + }; + }, [value, variant]); + // 监听滚动和窗口大小变化,更新下拉框位置 useEffect(() => { if (!showSuggestions) return; @@ -149,6 +209,53 @@ const VariableInput: React.FC = ({ // 处理键盘事件 const handleKeyDown = (e: React.KeyboardEvent) => { + const target = e.target as HTMLInputElement | HTMLTextAreaElement; + + // 处理变量整体删除(Backspace) + if (e.key === 'Backspace' && !showSuggestions) { + const result = handleBackspaceDelete( + value, + target.selectionStart || 0, + target.selectionEnd || 0 + ); + + if (result) { + e.preventDefault(); + onChange(result.newText); + + // 恢复光标位置 + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); + } + }, 0); + return; + } + } + + // 处理变量整体删除(Delete) + if (e.key === 'Delete' && !showSuggestions) { + const result = handleDeleteKey( + value, + target.selectionStart || 0, + target.selectionEnd || 0 + ); + + if (result) { + e.preventDefault(); + onChange(result.newText); + + // 恢复光标位置 + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(result.newCursorPos, result.newCursorPos); + } + }, 0); + return; + } + } + + // 建议框快捷键 if (!showSuggestions) return; switch (e.key) { @@ -215,6 +322,17 @@ const VariableInput: React.FC = ({ // 渲染输入框 const renderInput = () => { + const baseStyle = { + // 关键:使用 WebKit 专属属性使文字完全透明,但保留光标和选区 + WebkitTextFillColor: 'transparent', + color: 'transparent', // 降级方案 + caretColor: 'hsl(var(--foreground))', + // 保留选区高亮 + WebkitUserSelect: 'text', + userSelect: 'text', + } as React.CSSProperties; + + // 只有有值时才设置透明,这样 placeholder 可以正常显示 const commonProps = { ref: inputRef as any, value, @@ -222,6 +340,8 @@ const VariableInput: React.FC = ({ onKeyDown: handleKeyDown, placeholder, disabled, + className: 'variable-input', + style: value ? baseStyle : { caretColor: 'hsl(var(--foreground))' }, }; if (variant === 'input') { @@ -311,8 +431,24 @@ const VariableInput: React.FC = ({ return (
- {/* 输入框 */} - {renderInput()} + {/* 包装容器:输入框 + 高亮层 */} +
+ {/* 底层:实际的输入框(文字透明,光标可见) */} + {renderInput()} + + {/* 顶层:高亮层(绝对定位,覆盖在输入框上) */} + {value && ( +
+ +
+ )} +
{/* 变量提示弹窗 - 使用 Portal 渲染到 body */} {renderDropdown()} diff --git a/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx b/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx index 601fc214..4efa0e7a 100644 --- a/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx +++ b/frontend/src/pages/Workflow/Design/components/NodeConfigModal.tsx @@ -432,7 +432,7 @@ const NodeConfigModal: React.FC = ({ {isConfigurableNode(nodeDefinition) && nodeDefinition.inputMappingSchema && ( - 输入映射 + 输入
diff --git a/frontend/src/pages/Workflow/Design/nodes/JenkinsBuildNode.tsx b/frontend/src/pages/Workflow/Design/nodes/JenkinsBuildNode.tsx index 779c1754..922ad405 100644 --- a/frontend/src/pages/Workflow/Design/nodes/JenkinsBuildNode.tsx +++ b/frontend/src/pages/Workflow/Design/nodes/JenkinsBuildNode.tsx @@ -61,17 +61,30 @@ export const JenkinsBuildNodeDefinition: ConfigurableNodeDefinition = { title: "节点描述", description: "节点的详细说明", default: "通过Jenkins执行构建任务" - }, + } + }, + required: ["nodeName", "nodeCode", "jenkinsServerId"] + }, + inputMappingSchema: { + type: "object", + title: "输入", + description: "从上游节点接收的数据映射配置", + properties: { jenkinsServerId: { type: "number", title: "Jenkins服务器", description: "选择要使用的Jenkins服务器", 'x-dataSource': DataSourceType.JENKINS_SERVERS - } + }, + project: { + type: "string", + title: "项目", + description: "要触发构建的项目", + default: "" + }, }, - required: ["nodeName", "nodeCode", "jenkinsServerId"] + required: ["jenkinsServerId"] }, - // ✅ 输出能力定义(只读展示,传递给后端) outputs: [ { name: "buildNumber",