Skip to content

Commit 6289789

Browse files
xlorneclaude
andcommitted
feat: improve node title configuration UI
- Add Popover mode for variable picker with cursor position insertion - Change // @title to // @CUSTOM_SCRIPT for advanced mode identification - Add support for switching back from advanced to normal mode - Add reset button at bottom of config modal - Fix variable sorting to put form fields at the end - Fix Groovy syntax converter for consecutive variables - Add form fields support in node title strategy - Fix advanced mode display to show "(自定义配置)" instead of raw code Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a04c4bc commit 6289789

5 files changed

Lines changed: 277 additions & 50 deletions

File tree

frontend/packages/flow-pc/flow-pc-design/src/components/design-editor/node-components/strategy/TitleConfigModal.tsx

Lines changed: 221 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
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';
44
import { GroovyVariableMapping } from '@flow-engine/flow-types';
55
import { GroovyVariableService } from '@/services/groovy-variable-service';
66
import { TitleSyntaxConverter } from '@/utils/title-syntax-converter';
7-
import { VariablePicker } from './VariablePicker';
87

98
const { 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+
};

frontend/packages/flow-pc/flow-pc-design/src/components/design-editor/node-components/strategy/node-title.tsx

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import React, { useMemo, useRef, useState, useCallback } from 'react';
22
import { Form, Button, Space } from 'antd';
33
import { EditOutlined } from '@ant-design/icons';
44
import { Field, FieldRenderProps } from '@flowgram.ai/fixed-layout-editor';
5+
import { useDesignContext } from '@/components/design-panel/hooks/use-design-context';
6+
import { WorkflowFormManager } from '@/components/design-panel/manager/form';
57
import { GroovyVariableService } from '@/services/groovy-variable-service';
68
import { TitleSyntaxConverter } from '@/utils/title-syntax-converter';
79
import { TitleConfigModal } from './TitleConfigModal';
@@ -15,11 +17,31 @@ export const NodeTitleStrategy: React.FC = () => {
1517
// 使用 ref 保存 onChange 回调
1618
const onChangeRef = useRef<((value: string) => void) | null>(null);
1719

18-
// 获取表单字段(从context获取)
20+
// 从 design context 获取表单字段
21+
const { state } = useDesignContext();
22+
23+
// 获取表单字段(从 workflow form 中提取)
1924
const formFields = useMemo(() => {
20-
// TODO: 从 design context 获取当前流程的表单字段
21-
return [];
22-
}, []);
25+
const fields: Array<{ name: string; code: string }> = [];
26+
if (!state?.workflow?.form) {
27+
return fields;
28+
}
29+
const formManager = new WorkflowFormManager(state.workflow.form);
30+
// 获取主表单字段
31+
const mainFields = formManager.getFormFields(state.workflow.form.code);
32+
for (const field of mainFields) {
33+
fields.push({ name: field.name, code: field.code });
34+
}
35+
// 获取子表单字段
36+
const subForms = state.workflow.form.subForms || [];
37+
for (const subForm of subForms) {
38+
const subFields = formManager.getFormFields(subForm.code);
39+
for (const field of subFields) {
40+
fields.push({ name: `${subForm.name}.${field.name}`, code: field.code });
41+
}
42+
}
43+
return fields;
44+
}, [state?.workflow?.form]);
2345

2446
// 获取变量映射
2547
const mappings = GroovyVariableService.getAllMappings(formFields);
@@ -32,16 +54,12 @@ export const NodeTitleStrategy: React.FC = () => {
3254

3355
const mode = TitleSyntaxConverter.parseMode(script);
3456
if (mode === 'normal') {
57+
// Normal 模式:尝试解析为标签表达式
3558
const labelExpr = TitleSyntaxConverter.toLabelExpression(script, mappings);
36-
return labelExpr || script;
37-
}
38-
39-
// 尝试解析高级脚本
40-
const labelExpr = TitleSyntaxConverter.toLabelExpression(script, mappings);
41-
if (labelExpr !== null) {
42-
return labelExpr;
59+
return labelExpr || '(未配置)';
4360
}
4461

62+
// Advanced 模式:显示"用户自定义配置",不显示代码
4563
return '(自定义配置)';
4664
}, [mappings]);
4765

0 commit comments

Comments
 (0)