1- import React , { useState , useEffect , useRef } from 'react' ;
2- import { Modal , Input , Alert , Button , Space , message } from 'antd' ;
3- import { EditOutlined , CodeOutlined , RollbackOutlined } from '@ant-design/icons' ;
1+ import React , { useState , useEffect , useRef , useMemo } from 'react' ;
2+ import { Modal , Input , Alert , Button , Space , message , Popover , Empty } from 'antd' ;
3+ import { EditOutlined , CodeOutlined , RollbackOutlined , DeleteOutlined , SearchOutlined } from '@ant-design/icons' ;
44import { GroovyVariableMapping } from '@flow-engine/flow-types' ;
55import { GroovyVariableService } from '@/services/groovy-variable-service' ;
66import { TitleSyntaxConverter } from '@/utils/title-syntax-converter' ;
7- import { VariablePicker } from './VariablePicker' ;
87
98const { TextArea } = Input ;
109
@@ -31,7 +30,9 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
3130} ) => {
3231 const [ mode , setMode ] = useState < 'normal' | 'advanced' > ( 'normal' ) ;
3332 const [ content , setContent ] = useState ( '' ) ;
34- const [ showVariablePicker , setShowVariablePicker ] = useState ( false ) ;
33+ const [ cursorPosition , setCursorPosition ] = useState ( 0 ) ;
34+ const [ variablePickerOpen , setVariablePickerOpen ] = useState ( false ) ;
35+ const textareaRef = useRef < any > ( null ) ;
3536
3637 // 标记用户是否手动修改了模式(防止 useEffect 覆盖用户操作)
3738 const userModifiedModeRef = useRef ( false ) ;
@@ -63,9 +64,33 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
6364 setMode ( parsedMode ) ;
6465 } , [ script ] ) ;
6566
66- // 插入变量
67+ // 插入变量到光标位置
6768 const handleInsertVariable = ( mapping : GroovyVariableMapping ) => {
68- setContent ( prev => prev + `\${${ mapping . label } }` ) ;
69+ const variableText = `\${${ mapping . label } }` ;
70+ // 使用之前保存的光标位置插入变量
71+ const start = cursorPosition ;
72+ const newContent = content . substring ( 0 , start ) + variableText + content . substring ( start ) ;
73+ setContent ( newContent ) ;
74+ // 设置光标位置到插入变量之后
75+ setCursorPosition ( start + variableText . length ) ;
76+ // 聚焦到 textarea
77+ setTimeout ( ( ) => {
78+ if ( textareaRef . current ) {
79+ textareaRef . current . focus ( ) ;
80+ textareaRef . current . setSelectionRange ( start + variableText . length , start + variableText . length ) ;
81+ }
82+ } , 0 ) ;
83+ } ;
84+
85+ // 处理文本框变化时更新光标位置
86+ const handleContentChange = ( e : React . ChangeEvent < HTMLTextAreaElement > ) => {
87+ setContent ( e . target . value ) ;
88+ setCursorPosition ( e . target . selectionStart || 0 ) ;
89+ } ;
90+
91+ // 处理文本框聚焦时更新光标位置
92+ const handleTextareaFocus = ( e : React . FocusEvent < HTMLTextAreaElement > ) => {
93+ setCursorPosition ( e . target . selectionStart || 0 ) ;
6994 } ;
7095
7196 // 切换到高级模式
@@ -79,11 +104,28 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
79104 setMode ( 'advanced' ) ;
80105 } ;
81106
82- // 重置为普通模式
83- const handleResetToNormal = ( ) => {
107+ // 切换回普通模式
108+ const handleSwitchToNormal = ( ) => {
84109 userModifiedModeRef . current = true ;
85- setContent ( '' ) ;
86- setMode ( 'normal' ) ;
110+ // 尝试解析当前高级脚本为标签表达式
111+ const labelExpr = TitleSyntaxConverter . toLabelExpression ( content , mappings ) ;
112+ if ( labelExpr !== null ) {
113+ // 能解析,切换到普通模式并显示解析后的内容
114+ setContent ( labelExpr ) ;
115+ setMode ( 'normal' ) ;
116+ } else {
117+ // 无法解析,提示用户确认
118+ Modal . confirm ( {
119+ title : '切换回普通模式' ,
120+ content : '当前自定义代码无法解析为可视化标签表达式,切换后将丢失这些配置。是否继续?' ,
121+ okText : '确定' ,
122+ cancelText : '取消' ,
123+ onOk : ( ) => {
124+ setContent ( '' ) ;
125+ setMode ( 'normal' ) ;
126+ } ,
127+ } ) ;
128+ }
87129 } ;
88130
89131 // 确认
@@ -157,12 +199,27 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
157199 < div style = { sectionStyle } >
158200 { mode === 'normal' ? (
159201 < Space >
160- < Button
161- icon = { < EditOutlined /> }
162- onClick = { ( ) => setShowVariablePicker ( true ) }
202+ < Popover
203+ content = {
204+ < VariablePickerContent
205+ mappings = { mappings }
206+ onSelect = { handleInsertVariable }
207+ />
208+ }
209+ trigger = "click"
210+ placement = "bottomLeft"
211+ open = { variablePickerOpen }
212+ onOpenChange = { setVariablePickerOpen }
213+ overlayStyle = { { padding : 0 } }
214+ getPopupContainer = { ( trigger ) => trigger . parentElement || document . body }
163215 >
164- 插入变量
165- </ Button >
216+ < Button
217+ icon = { < EditOutlined /> }
218+ onClick = { ( ) => setVariablePickerOpen ( true ) }
219+ >
220+ 插入变量
221+ </ Button >
222+ </ Popover >
166223 < Button
167224 icon = { < CodeOutlined /> }
168225 onClick = { handleSwitchToAdvanced }
@@ -173,9 +230,9 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
173230 ) : (
174231 < Button
175232 icon = { < RollbackOutlined /> }
176- onClick = { handleResetToNormal }
233+ onClick = { handleSwitchToNormal }
177234 >
178- 重置
235+ 返回普通模式
179236 </ Button >
180237 ) }
181238 </ div >
@@ -186,8 +243,10 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
186243 { mode === 'normal' ? (
187244 < >
188245 < TextArea
246+ ref = { textareaRef }
189247 value = { content }
190- onChange = { e => setContent ( e . target . value ) }
248+ onChange = { handleContentChange }
249+ onFocus = { handleTextareaFocus }
191250 placeholder = "点击上方按钮插入变量,或直接输入文字内容"
192251 autoSize = { { minRows : 3 , maxRows : 6 } }
193252 />
@@ -197,24 +256,108 @@ export const TitleConfigModal: React.FC<TitleConfigModalProps> = ({
197256 </ >
198257 ) : (
199258 < TextArea
259+ ref = { textareaRef }
200260 value = { content }
201- onChange = { e => setContent ( e . target . value ) }
202- placeholder = '// @TITLE\nreturn "审批:" + request.getOperatorName()'
261+ onChange = { handleContentChange }
262+ onFocus = { handleTextareaFocus }
263+ placeholder = '// @CUSTOM_SCRIPT\ndef run(request){\n return "审批:" + request.getOperatorName()\n}'
203264 autoSize = { { minRows : 6 , maxRows : 10 } }
204265 style = { { fontFamily : 'Courier New, monospace' } }
205266 />
206267 ) }
207268 </ div >
269+
270+ { /* 底部操作区 */ }
271+ < div style = { footerStyle } >
272+ < Button
273+ danger
274+ icon = { < DeleteOutlined /> }
275+ onClick = { ( ) => {
276+ Modal . confirm ( {
277+ title : '重置设置' ,
278+ content : '确定要清除所有标题配置吗?' ,
279+ okText : '确定' ,
280+ cancelText : '取消' ,
281+ onOk : ( ) => {
282+ userModifiedModeRef . current = true ;
283+ setContent ( '' ) ;
284+ setMode ( 'normal' ) ;
285+ } ,
286+ } ) ;
287+ } }
288+ >
289+ 重置设置
290+ </ Button >
291+ </ div >
208292 </ div >
209293 </ Modal >
294+ </ >
295+ ) ;
296+ } ;
297+
298+ // Popover 版本的变量选择器内容
299+ const VariablePickerContent : React . FC < {
300+ mappings : GroovyVariableMapping [ ] ;
301+ onSelect : ( mapping : GroovyVariableMapping ) => void ;
302+ } > = ( { mappings, onSelect } ) => {
303+ const [ searchText , setSearchText ] = useState ( '' ) ;
304+
305+ // 阻止事件冒泡,防止 Popover 关闭
306+ const handleInputChange = ( e : React . ChangeEvent < HTMLInputElement > ) => {
307+ e . stopPropagation ( ) ;
308+ setSearchText ( e . target . value ) ;
309+ } ;
210310
211- < VariablePicker
212- mappings = { mappings }
213- onSelect = { handleInsertVariable }
214- visible = { showVariablePicker }
215- onClose = { ( ) => setShowVariablePicker ( false ) }
311+ const filteredMappings = useMemo ( ( ) => {
312+ if ( ! searchText ) {
313+ return mappings ;
314+ }
315+ const lowerSearch = searchText . toLowerCase ( ) ;
316+ return mappings . filter (
317+ m =>
318+ m . label . toLowerCase ( ) . includes ( lowerSearch ) ||
319+ m . value . toLowerCase ( ) . includes ( lowerSearch )
320+ ) ;
321+ } , [ mappings , searchText ] ) ;
322+
323+ const groupedMappings = useMemo ( ( ) => {
324+ return GroovyVariableService . groupByTag ( filteredMappings ) ;
325+ } , [ filteredMappings ] ) ;
326+
327+ return (
328+ < div style = { pickerContainerStyle } onClick = { e => e . stopPropagation ( ) } >
329+ < Input
330+ placeholder = "搜索变量..."
331+ prefix = { < SearchOutlined style = { { color : '#999' } } /> }
332+ value = { searchText }
333+ onChange = { handleInputChange }
334+ allowClear
335+ style = { { marginBottom : 8 } }
216336 />
217- </ >
337+ < div style = { pickerListStyle } >
338+ { groupedMappings . size === 0 ? (
339+ < Empty description = "未找到匹配的变量" image = { Empty . PRESENTED_IMAGE_SIMPLE } />
340+ ) : (
341+ Array . from ( groupedMappings . entries ( ) ) . map ( ( [ tag , variables ] ) => (
342+ < div key = { tag } >
343+ < div style = { pickerGroupTitleStyle } > { tag } </ div >
344+ < div style = { pickerItemsStyle } >
345+ { variables . map ( variable => (
346+ < div
347+ key = { variable . label }
348+ style = { pickerItemStyle }
349+ onClick = { ( ) => onSelect ( variable ) }
350+ >
351+ < span style = { pickerItemLabelStyle } > { variable . label } </ span >
352+ < span style = { pickerItemValueStyle } > { variable . value } </ span >
353+ </ div >
354+ ) ) }
355+ </ div >
356+ </ div >
357+ ) )
358+ ) }
359+ </ div >
360+ </ div >
218361 ) ;
219362} ;
220363
@@ -248,3 +391,54 @@ const hintStyle: React.CSSProperties = {
248391 fontSize : 12 ,
249392 color : 'rgba(0, 0, 0, 0.25)' ,
250393} ;
394+
395+ const footerStyle : React . CSSProperties = {
396+ display : 'flex' ,
397+ justifyContent : 'flex-start' ,
398+ paddingTop : 8 ,
399+ borderTop : '1px solid #f0f0f0' ,
400+ } ;
401+
402+ const pickerContainerStyle : React . CSSProperties = {
403+ width : 400 ,
404+ maxHeight : 300 ,
405+ overflow : 'auto' ,
406+ } ;
407+
408+ const pickerListStyle : React . CSSProperties = {
409+ maxHeight : 250 ,
410+ overflow : 'auto' ,
411+ } ;
412+
413+ const pickerGroupTitleStyle : React . CSSProperties = {
414+ fontSize : 12 ,
415+ fontWeight : 500 ,
416+ color : 'rgba(0, 0, 0, 0.45)' ,
417+ padding : '8px 0 4px' ,
418+ } ;
419+
420+ const pickerItemsStyle : React . CSSProperties = {
421+ display : 'flex' ,
422+ flexWrap : 'wrap' ,
423+ gap : 8 ,
424+ } ;
425+
426+ const pickerItemStyle : React . CSSProperties = {
427+ padding : '4px 8px' ,
428+ backgroundColor : '#f5f5f5' ,
429+ borderRadius : 4 ,
430+ cursor : 'pointer' ,
431+ display : 'flex' ,
432+ flexDirection : 'column' ,
433+ gap : 2 ,
434+ } ;
435+
436+ const pickerItemLabelStyle : React . CSSProperties = {
437+ fontSize : 13 ,
438+ color : 'rgba(0, 0, 0, 0.88)' ,
439+ } ;
440+
441+ const pickerItemValueStyle : React . CSSProperties = {
442+ fontSize : 11 ,
443+ color : 'rgba(0, 0, 0, 0.45)' ,
444+ } ;
0 commit comments