重构前端逻辑

This commit is contained in:
dengqichen 2025-11-06 16:10:37 +08:00
parent 6318ca6241
commit 0ab4472a2f
27 changed files with 794 additions and 301 deletions

View File

@ -81,6 +81,7 @@
"devDependencies": {
"@types/dagre": "^0.7.52",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.20",
"@types/node": "^20.17.10",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
@ -95,6 +96,7 @@
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"fs-extra": "^11.2.0",
"lodash": "^4.17.21",
"lucide-react": "^0.469.0",
"postcss": "^8.4.49",
"tailwind-merge": "^2.6.0",

View File

@ -213,6 +213,9 @@ importers:
'@types/fs-extra':
specifier: ^11.0.4
version: 11.0.4
'@types/lodash':
specifier: ^4.17.20
version: 4.17.20
'@types/node':
specifier: ^20.17.10
version: 20.17.10
@ -255,6 +258,9 @@ importers:
fs-extra:
specifier: ^11.2.0
version: 11.2.0
lodash:
specifier: ^4.17.21
version: 4.17.21
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@18.3.1)

View File

@ -83,7 +83,7 @@ SelectOrVariableInput 已经集成了 CodeMirror默认启用
显示补全列表:
- ${jenkins.buildNumber}
- ${jenkins.buildUrl}
- ${form.applicationName}
- ${applicationName}
- ...
```

View File

@ -112,6 +112,9 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
const onEditingChangeRef = useRef(onEditingChange);
const editingTimeoutRef = useRef<number | null>(null);
// ✅ 添加用户编辑状态标记(防止编辑时被外部 value 覆盖)
const isUserEditingRef = useRef(false);
// 同步 onChange 和 onEditingChange 引用
useEffect(() => {
onChangeRef.current = onChange;
@ -120,6 +123,9 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
// 智能值转换处理
const handleValueChange = (newValue: string) => {
// ✅ 标记用户正在编辑
isUserEditingRef.current = true;
// 通知外部:用户开始编辑
onEditingChangeRef.current?.(true);
@ -128,9 +134,10 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
clearTimeout(editingTimeoutRef.current);
}
// 300ms 后通知编辑结束
// 300ms 后通知编辑结束并重置编辑状态
editingTimeoutRef.current = window.setTimeout(() => {
onEditingChangeRef.current?.(false);
isUserEditingRef.current = false; // ✅ 重置编辑状态
}, 300);
// 值转换逻辑
@ -152,14 +159,15 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
// 收集所有可用变量
const allVariables = React.useMemo(() => {
// 表单字段变量
// 表单字段变量(去除 form. 前缀,直接使用原始字段名)
const formVariables = formFields.map(field => ({
nodeId: 'form',
nodeName: '启动表单',
fieldName: field.name,
fieldType: field.type,
displayText: `\${form.${field.name}}`,
fullText: `form.${field.name}`,
fieldDescription: field.label, // ✅ 使用表单字段的 label 作为描述
displayText: `\${${field.name}}`,
fullText: `${field.name}`,
}));
// 节点变量
@ -199,7 +207,8 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
options: filteredVariables.map(v => ({
label: v.displayText,
type: 'variable',
info: `${v.nodeName} - ${v.fieldName}`,
// ✅ 优先显示字段描述,若无描述则显示"节点名 - 字段名"
info: v.fieldDescription || `${v.nodeName} - ${v.fieldName}`,
apply: v.displayText,
})),
};
@ -322,9 +331,15 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
}, [variant, rows, theme, disabled, placeholder, variableCompletion]);
// 当外部 value 变化时,更新编辑器内容
// ✅ 添加保护:用户正在编辑时,不同步外部 value防止删除时恢复
useEffect(() => {
if (!viewRef.current) return;
// ⚠️ 如果用户正在编辑,跳过同步(防止删除操作被覆盖)
if (isUserEditingRef.current) {
return;
}
const stringValue = String(value || '');
const currentValue = viewRef.current.state.doc.toString();
if (currentValue !== stringValue) {

View File

@ -36,7 +36,7 @@ export interface VariableInputProps {
/** 当前节点ID用于过滤前序节点*/
currentNodeId: string;
/** 表单字段列表(用于支持 ${form.xxx} 变量)*/
/** 表单字段列表(用于支持 ${xxx} 变量)*/
formFields?: FormField[];
/** 渲染类型 */

View File

@ -36,6 +36,7 @@ import { Form, message } from 'antd';
import type { FieldConfig, FormConfig } from './types';
import { useFormCore } from './hooks/useFormCore';
import { computeFormLayout } from './utils/formLayoutHelper';
import { transformToNestedObject } from './utils/pathHelper';
import FormFieldsRenderer from './components/FormFieldsRenderer';
import './styles.css';
@ -63,16 +64,32 @@ const FormPreview = forwardRef<FormPreviewRef, FormPreviewProps>(({ fields, form
const [form] = Form.useForm();
// 使用核心 hook 管理表单状态
const { formData, setFormData, fieldStates, handleValuesChange } = useFormCore({ fields, form });
const { formData, setFormData, fieldStates, handleValuesChange: coreHandleValuesChange } = useFormCore({ fields, form });
// 🎯 自定义值变化处理:实时转换为嵌套对象
const handleValuesChange = (changedValues: any, allValues: any) => {
// 先调用核心的处理逻辑(联动规则等)
coreHandleValuesChange(changedValues, allValues);
// 实时转换为嵌套对象并更新显示
const nestedValues = transformToNestedObject(allValues);
setFormData(nestedValues);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
console.log('表单提交数据:', values);
setFormData(values);
const flatValues = await form.validateFields();
console.log('📋 表单验证通过(扁平格式):', flatValues);
// 🎯 路径转换:将扁平数据转换为嵌套对象
const nestedValues = transformToNestedObject(flatValues);
console.log('🔄 转换为嵌套对象:', nestedValues);
setFormData(nestedValues);
message.success('表单提交成功!请查看控制台');
} catch (error) {
message.error('请填写必填项');
// ✅ 验证失败 - 不显示弹窗,让表单元素自己显示错误即可
console.error('❌ 表单验证失败:', error);
}
};

View File

@ -51,6 +51,7 @@ import { COMPONENT_LIST } from './config';
import { ComponentsContext } from './Designer';
import { useFormCore } from './hooks/useFormCore';
import { computeFormLayout } from './utils/formLayoutHelper';
import { transformToNestedObject, transformToFlatObject } from './utils/pathHelper';
import FormFieldsRenderer from './components/FormFieldsRenderer';
import './styles.css';
@ -131,29 +132,79 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
// 使用核心 hook 管理表单状态
const { setFormData, fieldStates, handleValuesChange: coreHandleValuesChange } = useFormCore({ fields, form });
// 🎯 初始化表单值(当 value prop 传入时)
// 使用 ref 标记是否已初始化,防止用户编辑后被重新覆盖
const isInitialized = React.useRef(false);
React.useEffect(() => {
// ✅ 只在第一次且有初始值时设置,避免覆盖用户已填写的数据
if (!isInitialized.current && props.value && Object.keys(props.value).length > 0) {
// 收集所有字段名称(包括嵌套字段)
const collectFieldNames = (fieldList: FieldConfig[]): string[] => {
const names: string[] = [];
fieldList.forEach(field => {
if (field.name) {
names.push(field.name);
}
// 递归处理栅格布局内的字段
if (field.type === 'grid' && field.children) {
field.children.forEach(columnFields => {
names.push(...collectFieldNames(columnFields));
});
}
});
return names;
};
const fieldNames = collectFieldNames(fields);
// 将嵌套对象转换为扁平格式Ant Design Form 需要)
const flatValues = transformToFlatObject(props.value, fieldNames);
form.setFieldsValue(flatValues);
isInitialized.current = true; // 标记已初始化
if (import.meta.env.DEV) {
console.log('📥 表单初始值已设置:', flatValues);
}
}
}, [props.value, form, fields]);
// 🎯 自定义值变化处理:实时转换为嵌套对象
const handleValuesChange = (changedValues: any, allValues: any) => {
// 先调用核心的处理逻辑(联动规则等)
coreHandleValuesChange(changedValues, allValues);
// 实时转换为嵌套对象(用于父组件监听)
if (props.onChange) {
const nestedValues = transformToNestedObject(allValues);
props.onChange(nestedValues);
}
};
const handleSubmit = async () => {
try {
// 1. 表单验证
const values = await form.validateFields();
console.log('📋 表单验证通过:', values);
setFormData(values);
const flatValues = await form.validateFields();
// 2. beforeSubmit 钩子 - 允许修改提交数据或中断提交
let finalValues = values;
// 🎯 2. 路径转换:将扁平数据转换为嵌套对象
// 字段名支持点号和数组索引,如 'jenkins.serverId' 或 'users[0].name'
const nestedValues = transformToNestedObject(flatValues);
setFormData(nestedValues);
// 3. beforeSubmit 钩子 - 允许修改提交数据或中断提交
let finalValues = nestedValues;
if (props.beforeSubmit) {
try {
const result = await props.beforeSubmit(values);
const result = await props.beforeSubmit(nestedValues);
if (result === false) {
console.log('⚠️ beforeSubmit 返回 false提交已取消');
message.warning('提交已取消');
return;
}
if (result && typeof result === 'object') {
finalValues = result;
console.log('🔄 beforeSubmit 修改了提交数据:', finalValues);
}
} catch (error) {
console.error('❌ beforeSubmit 钩子执行失败:', error);
console.error('❌ beforeSubmit 钩子失败:', error);
message.error('提交前处理失败');
if (props.onError) {
await props.onError(error);
@ -167,7 +218,6 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
if (onSubmit) {
try {
submitResult = await onSubmit(finalValues);
console.log('✅ 表单提交成功:', submitResult);
} catch (error) {
console.error('❌ 表单提交失败:', error);
// 提交失败时触发 onError 钩子
@ -186,16 +236,15 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
if (props.afterSubmit) {
try {
await props.afterSubmit(submitResult);
console.log('✅ afterSubmit 钩子执行完成');
} catch (error) {
console.error('⚠️ afterSubmit 钩子执行失败:', error);
console.error('⚠️ afterSubmit 钩子失败:', error);
// afterSubmit 失败不影响整体提交流程,只记录日志
}
}
} catch (error) {
// 验证失败
console.error('❌ 表单验证失败:', error);
message.error('请填写必填项');
// 验证失败 - 不显示弹窗,让表单元素自己显示错误即可
console.error('❌ 表单验证/提交失败:', error);
// ✅ 移除 message.error(),错误信息已在表单元素上显示
// 触发 onError 钩子(验证失败也算错误)
if (props.onError) {
@ -209,14 +258,6 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
setFormData({});
};
// 监听表单值变化
const handleValuesChange = (_: any, allValues: Record<string, any>) => {
coreHandleValuesChange(_, allValues); // 调用核心 hook 的处理
if (props.onChange) {
props.onChange(allValues); // 通知外部
}
};
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
submit: handleSubmit,

View File

@ -89,11 +89,21 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
);
}
// ✅ 栅格布局:只在设计模式下渲染,运行时模式由 GridFieldPreview 处理
if (field.type === 'grid') {
// 预览/运行时模式:不在 FieldRenderer 层处理栅格
// 因为 FormFieldsRenderer 已经使用 GridFieldPreview 处理了
// 避免重复渲染和丢失 fieldStates
if (isPreview) {
console.warn('⚠️ FieldRenderer 在预览模式下不应处理 grid 类型,应由 GridFieldPreview 处理');
return null;
}
// 设计模式:使用 GridField支持拖拽、选中等设计器功能
return (
<GridField
field={field}
isPreview={isPreview}
isPreview={false}
selectedFieldId={selectedFieldId}
onSelectField={onSelectField}
onCopyField={onCopyField}

View File

@ -4,7 +4,7 @@
*/
import React from 'react';
import { Form } from 'antd';
import { Form, Input } from 'antd';
import type { FieldConfig, FormConfig } from '../types';
import type { FieldState } from '../utils/linkageHelper';
import { mergeValidationRules } from '../utils/validationHelper';
@ -19,7 +19,7 @@ import GridFieldPreview from './GridFieldPreview';
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
return (
<Form.Item name={fieldName} noStyle>
<input type="hidden" />
<Input type="hidden" />
</Form.Item>
);
};
@ -69,6 +69,7 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
key={field.id}
field={field}
formConfig={formConfig}
fieldStates={fieldStates} // ✅ 传递 fieldStates 以支持栅格内字段的联动规则
/>
);
}

View File

@ -4,8 +4,9 @@
*/
import React from 'react';
import { Row, Col, Form } from 'antd';
import { Row, Col, Form, Input } from 'antd';
import type { FieldConfig, FormConfig } from '../types';
import type { FieldState } from '../utils/linkageHelper';
import { mergeValidationRules } from '../utils/validationHelper';
import { computeFieldState, FieldClassifier } from '../utils/fieldStateHelper';
import { computeGridColSpan } from '../utils/formLayoutHelper';
@ -13,11 +14,12 @@ import FieldRenderer from './FieldRenderer';
/**
* 使
* UI
*/
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
return (
<Form.Item name={fieldName} noStyle>
<input type="hidden" />
<Input type="hidden" />
</Form.Item>
);
};
@ -25,19 +27,21 @@ const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
interface GridFieldPreviewProps {
field: FieldConfig;
formConfig?: FormConfig;
fieldStates?: Record<string, FieldState>; // ✅ 新增:支持联动规则状态
}
const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
field,
formConfig
formConfig,
fieldStates = {} // ✅ 默认值为空对象
}) => {
const columns = field.columns || 2;
const children = field.children || Array(columns).fill([]);
const renderFieldItem = (childField: FieldConfig) => {
// 计算字段最终状态(这里联动规则状态需要从外层传入,暂时只使用字段自身属性)
// TODO: 如果需要支持栅格内的联动规则,需要将 fieldStates 作为 props 传入
const computedState = computeFieldState(childField);
// ✅ 使用传入的 fieldStates 计算字段最终状态
const fieldState = fieldStates[childField.name] || {};
const computedState = computeFieldState(childField, fieldState);
// 如果字段被隐藏,使用隐藏字段组件
if (!computedState.isVisible) {
@ -51,7 +55,22 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
// 使用统一的字段分类器判断布局组件
const fieldType = FieldClassifier.classify(childField);
if (fieldType === 'layout' || fieldType === 'grid') {
// ✅ 对于嵌套的栅格,递归调用 GridFieldPreview 而不是 FieldRenderer
// 这样可以正确传递 fieldStates避免渲染空的容器 div
if (fieldType === 'grid') {
return (
<GridFieldPreview
key={childField.id}
field={childField}
formConfig={formConfig}
fieldStates={fieldStates}
/>
);
}
// 其他布局组件(文本、分割线)正常渲染
if (fieldType === 'layout') {
return (
<div key={childField.id} style={{ marginBottom: 8 }}>
<FieldRenderer field={childField} isPreview={true} />
@ -88,6 +107,26 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
);
};
// ✅ 检查栅格内是否有可见字段
const hasVisibleFields = children.some((columnFields) =>
columnFields.some((childField) => {
const fieldState = fieldStates[childField.name] || {};
const computedState = computeFieldState(childField, fieldState);
return computedState.isVisible;
})
);
// ✅ 如果栅格内所有字段都被隐藏,不渲染容器 div
if (!hasVisibleFields) {
return (
<>
{children.flat().map((childField: FieldConfig) => (
<HiddenField key={childField.id} fieldName={childField.name} />
))}
</>
);
}
return (
<div style={{ marginBottom: 8 }}>
<Row gutter={field.gutter || 16}>

View File

@ -24,6 +24,7 @@ import { DataSourceType, CascadeDataSourceType } from '@/domain/dataSource';
import CascadeOptionEditor from './CascadeOptionEditor';
import ValidationRuleEditor from './ValidationRuleEditor';
import LinkageRuleEditor from './LinkageRuleEditor';
import { GRID_DEFAULT_CONFIG, getDefaultGridChildren } from '../config';
const { Text } = Typography;
@ -80,6 +81,16 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
// 深度合并,特别处理 apiDataSource 嵌套对象
let updatedField = { ...selectedField };
// 🎯 自动勾选必填:如果添加了验证规则,自动设置为必填
if ('validationRules' in changedValues) {
const rules = changedValues.validationRules;
if (rules && rules.length > 0 && !updatedField.required) {
updatedField.required = true;
form.setFieldValue('required', true);
console.log('✅ 已添加验证规则,自动勾选必填');
}
}
// 如果改变的是数据源类型
if ('dataSourceType' in changedValues) {
updatedField.dataSourceType = changedValues.dataSourceType;
@ -187,20 +198,24 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
// 🔌 检查是否有自定义配置组件(插件式扩展)
const componentMeta = allComponents.find(c => c.type === selectedField.type);
console.log('🔍 PropertyPanel Debug:', {
selectedFieldType: selectedField.type,
allComponentsCount: allComponents.length,
allComponentTypes: allComponents.map(c => c.type),
componentMeta: componentMeta ? {
type: componentMeta.type,
label: componentMeta.label,
hasPropertyConfig: !!componentMeta.PropertyConfigComponent
} : null
});
if (import.meta.env.DEV) {
console.log('🔍 PropertyPanel Debug:', {
selectedFieldType: selectedField.type,
allComponentsCount: allComponents.length,
allComponentTypes: allComponents.map(c => c.type),
componentMeta: componentMeta ? {
type: componentMeta.type,
label: componentMeta.label,
hasPropertyConfig: !!componentMeta.PropertyConfigComponent
} : null
});
}
if (componentMeta?.PropertyConfigComponent) {
const CustomConfig = componentMeta.PropertyConfigComponent;
console.log('✅ 使用自定义配置组件:', componentMeta.type);
if (import.meta.env.DEV) {
console.log('✅ 使用自定义配置组件:', componentMeta.type);
}
return (
<div className="form-property-panel-content">
<CustomConfig
@ -211,7 +226,9 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
);
}
console.log('⚠️ 未找到自定义配置组件,使用默认配置');
if (import.meta.env.DEV) {
console.log('⚠️ 未找到自定义配置组件,使用默认配置');
}
const hasOptions = ['select', 'radio', 'checkbox'].includes(selectedField.type);
const isCascader = selectedField.type === 'cascader';
@ -384,12 +401,8 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
size="small"
icon={<PlusOutlined />}
onClick={() => {
// 如果没有配置 columnSpans先初始化为当前列数的平均分配
const currentSpans = selectedField.columnSpans || (() => {
const cols = selectedField.columns || 2;
const avgSpan = Math.floor(24 / cols);
return Array(cols).fill(avgSpan);
})();
// 如果没有配置 columnSpans使用默认配置
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
const total = currentSpans.reduce((a, b) => a + b, 0);
if (total >= 24) {
@ -417,7 +430,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
</div>
<Space direction="vertical" style={{ width: '100%' }} size="small">
{(selectedField.columnSpans || [12, 12]).map((span, index) => (
{(selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans).map((span, index) => (
<Space key={index} style={{ display: 'flex', width: '100%' }} align="center">
<Text style={{ minWidth: 20 }}>{index + 1}.</Text>
<InputNumber
@ -426,7 +439,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
value={span}
onChange={(value) => {
if (value) {
const currentSpans = selectedField.columnSpans || [12, 12];
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
const newSpans = [...currentSpans];
newSpans[index] = value;
onFieldChange({
@ -442,17 +455,19 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
icon={<DeleteOutlined />}
danger
onClick={() => {
const currentSpans = selectedField.columnSpans || [12, 12];
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
const newSpans = currentSpans.filter((_, i) => i !== index);
const newChildren = (selectedField.children || [[], []]).filter((_, i) => i !== index);
const currentChildren = selectedField.children || getDefaultGridChildren();
const newChildren = currentChildren.filter((_, i) => i !== index);
// 如果删除后没有列了,重置为默认的2列配置
// 如果删除后没有列了,重置为默认配置
if (newSpans.length === 0) {
onFieldChange({
...selectedField,
columnSpans: [12, 12],
columns: 2,
children: [[], []],
columns: GRID_DEFAULT_CONFIG.columns,
columnSpans: [...GRID_DEFAULT_CONFIG.columnSpans], // 创建新数组
gutter: GRID_DEFAULT_CONFIG.gutter,
children: getDefaultGridChildren(),
});
} else {
onFieldChange({
@ -468,7 +483,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
))}
</Space>
<div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
{(selectedField.columnSpans || [12, 12]).reduce((a: number, b: number) => a + b, 0)} / 24
{(selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans).reduce((a: number, b: number) => a + b, 0)} / 24
</div>
<Divider style={{ margin: '16px 0' }} />

View File

@ -17,25 +17,31 @@ interface ValidationRuleEditorProps {
const ValidationRuleEditor: React.FC<ValidationRuleEditorProps> = ({ value = [], onChange }) => {
const handleAddRule = () => {
const newRule: ValidationRule = {
type: 'required',
type: 'pattern', // 默认为正则表达式
message: '',
trigger: 'blur',
};
const newRules = [...value, newRule];
console.log(' [ValidationRuleEditor] 添加验证规则:', newRules);
if (import.meta.env.DEV) {
console.log(' [ValidationRuleEditor] 添加验证规则:', newRules);
}
onChange?.(newRules);
};
const handleDeleteRule = (index: number) => {
const newRules = value.filter((_, i) => i !== index);
console.log('🗑️ [ValidationRuleEditor] 删除验证规则:', newRules);
if (import.meta.env.DEV) {
console.log('🗑️ [ValidationRuleEditor] 删除验证规则:', newRules);
}
onChange?.(newRules);
};
const handleRuleChange = (index: number, field: keyof ValidationRule, fieldValue: any) => {
const newRules = [...value];
newRules[index] = { ...newRules[index], [field]: fieldValue };
console.log(`✏️ [ValidationRuleEditor] 修改验证规则 [${field}]:`, newRules);
if (import.meta.env.DEV) {
console.log(`✏️ [ValidationRuleEditor] 修改验证规则 [${field}]:`, newRules);
}
onChange?.(newRules);
};
@ -125,7 +131,6 @@ const ValidationRuleEditor: React.FC<ValidationRuleEditorProps> = ({ value = [],
value={rule.type}
onChange={(val) => handleRuleChange(index, 'type', val)}
>
<Select.Option value="required"></Select.Option>
<Select.Option value="pattern"></Select.Option>
<Select.Option value="min"></Select.Option>
<Select.Option value="max"></Select.Option>

View File

@ -3,6 +3,18 @@
*/
import React from 'react';
// 🎯 栅格布局默认配置(统一管理)
export const GRID_DEFAULT_CONFIG = {
columns: 3,
columnSpans: [8, 8, 8],
gutter: 16,
};
// 生成默认的栅格子列数组
export const getDefaultGridChildren = (columns: number = GRID_DEFAULT_CONFIG.columns) => {
return Array(columns).fill([]);
};
import {
FormOutlined,
FontSizeOutlined,
@ -65,10 +77,8 @@ export const COMPONENT_LIST: ComponentMeta[] = [
icon: BorderOutlined,
category: '布局字段',
defaultConfig: {
columns: 2,
columnSpans: [12, 12], // 默认两列各占12格
gutter: 16,
children: [[], []], // 初始化两列空数组
...GRID_DEFAULT_CONFIG,
children: getDefaultGridChildren(), // 初始化列数组
},
},
{

View File

@ -1,4 +1,4 @@
/**
/*
* 表单设计器样式
*/
@ -36,7 +36,7 @@
height: 100%;
display: flex;
flex-direction: column;
background: #fafafa;
background: #fff;
border-right: 1px solid #e8e8e8;
}
@ -64,17 +64,15 @@
display: flex;
align-items: center;
justify-content: center;
padding: 8px 6px;
background: #fff;
gap: 6px;
padding: 10px 8px;
border: 1px solid #d9d9d9;
border-radius: 6px;
transition: all 0.3s ease;
user-select: none;
border-radius: 4px;
cursor: move;
font-size: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.2s ease;
background: #fff;
user-select: none;
font-size: 13px;
min-height: 36px;
}
@ -115,9 +113,9 @@
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
border: 2px dashed #d9d9d9;
border-radius: 8px;
height: 100%;
color: #8c8c8c;
font-size: 14px;
background: #fafafa;
}
@ -131,14 +129,14 @@
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
gap: 8px;
padding: 16px;
margin-bottom: 20px;
background: #fff;
border: 2px solid #e8e8e8;
border-radius: 8px;
border: 1px solid #e8e8e8;
border-radius: 4px;
margin-bottom: 16px;
transition: all 0.2s ease;
cursor: pointer;
transition: all 0.3s ease;
}
/* 栅格布局字段项特殊样式 - 更大的间距 */
@ -193,104 +191,85 @@
opacity: 1;
}
/* 确保所有表单组件高度一致 */
.ant-form-item .ant-input:not(textarea),
.ant-form-item .ant-input-number,
.ant-form-item .ant-input-number-input-wrap,
.ant-form-item .ant-select-selector,
.ant-form-item .ant-picker {
min-height: 32px !important;
height: 32px !important;
/* 属性面板 */
.form-designer-property-panel {
width: 320px;
background: #fff;
border-left: 1px solid #e8e8e8;
overflow-y: auto;
flex-shrink: 0;
}
/* textarea 使用 auto 高度以支持多行 */
.ant-form-item textarea.ant-input {
height: auto !important;
min-height: auto !important;
}
.ant-form-item .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
height: 32px !important;
/* 辅助类 */
.flex-center {
display: flex;
align-items: center;
padding: 0 11px !important;
justify-content: center;
}
.ant-form-item .ant-select-selection-search-input {
height: 30px !important;
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.ant-form-item .ant-input-number-input {
height: 30px !important;
.cursor-pointer {
cursor: pointer;
}
/* 中等尺寸 */
.ant-form-middle .ant-input:not(textarea),
.ant-form-middle .ant-input-number,
.ant-form-middle .ant-input-number-input-wrap,
.ant-form-middle .ant-select-selector,
.ant-form-middle .ant-picker {
min-height: 32px !important;
height: 32px !important;
.cursor-move {
cursor: move;
}
.ant-form-middle textarea.ant-input {
height: auto !important;
min-height: auto !important;
/* 栅格拖拽占位符 */
.form-designer-grid-drop-zone {
min-height: 100px;
border: 2px dashed #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 12px;
transition: all 0.2s ease;
}
.ant-form-middle .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
height: 32px !important;
.form-designer-grid-drop-zone.active {
border-color: #1890ff;
background: #f0f5ff;
color: #1890ff;
}
/* 大尺寸 */
.ant-form-large .ant-input:not(textarea),
.ant-form-large .ant-input-number,
.ant-form-large .ant-input-number-input-wrap,
.ant-form-large .ant-select-selector,
.ant-form-large .ant-picker {
min-height: 40px !important;
height: 40px !important;
/* 栅格列的拖拽区域 */
.form-designer-grid-column {
min-height: 80px;
position: relative;
}
.ant-form-large textarea.ant-input {
height: auto !important;
min-height: auto !important;
/* 栅格列内的字段项 - 移除外部间距,让栅格的 gutter 控制间距 */
.form-designer-grid-column .form-designer-field-item {
margin-bottom: 8px;
}
.ant-form-large .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
height: 40px !important;
.form-designer-grid-column .form-designer-field-item:last-child {
margin-bottom: 0;
}
/* 小尺寸 */
.ant-form-small .ant-input:not(textarea),
.ant-form-small .ant-input-number,
.ant-form-small .ant-input-number-input-wrap,
.ant-form-small .ant-select-selector,
.ant-form-small .ant-picker {
min-height: 24px !important;
height: 24px !important;
/* 栅格的拖拽提示 */
.form-designer-grid-hint {
padding: 8px;
text-align: center;
color: #8c8c8c;
font-size: 12px;
border: 1px dashed #d9d9d9;
border-radius: 4px;
background: #fafafa;
}
.ant-form-small textarea.ant-input {
height: auto !important;
min-height: auto !important;
}
.ant-form-small .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
height: 24px !important;
}
/* 插入指示器脉冲动画 */
@keyframes dropIndicatorPulse {
0%, 100% {
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.4);
}
50% {
opacity: 1;
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.1);
}
/* === 紧凑模式 === */
/* 通用样式 */
.form-container-padding {
padding: 24px;
}
/* 表单渲染器和预览器的紧凑间距样式 */
@ -363,20 +342,10 @@
font-size: 12px;
}
/* 只读字段样式:灰色背景,不可编辑 */
.ant-input[readonly],
.ant-input:read-only {
background-color: #f5f5f5 !important;
cursor: not-allowed !important;
color: #00000040 !important;
}
.ant-input-number-disabled {
background-color: #f5f5f5 !important;
cursor: not-allowed !important;
color: #00000040 !important;
}
/* === 只读状态样式 === */
/* 对于 readonly 字段,使用灰色背景并禁用光标 */
input.ant-input[readonly],
input.ant-input:read-only,
textarea.ant-input[readonly],
textarea.ant-input:read-only {
background-color: #f5f5f5 !important;
@ -384,3 +353,24 @@ textarea.ant-input:read-only {
color: #00000040 !important;
}
/* ===== 🎯 表单错误提示不占用空间 ===== */
/* 使用绝对定位,避免表单抖动 */
.ant-form-item-explain-error {
position: absolute !important;
z-index: 1000;
background: #fff;
padding: 4px 8px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
margin-top: 2px;
font-size: 12px;
max-width: 300px;
word-wrap: break-word;
white-space: normal;
}
/* 确保 Form.Item 不为错误提示预留空间 */
.ant-form-item-explain,
.ant-form-item-extra {
min-height: 0 !important;
}

View File

@ -0,0 +1,102 @@
/**
* lodash
*
*
*
* - : 'jenkins.serverId' { jenkins: { serverId: value } }
* - : 'users[0].name' { users: [{ name: value }] }
* - : 'company.users[0].profile.email'
* - : 'username' { username: value }
*/
import { set, get } from 'lodash';
/**
*
* @param formData { 'jenkins.serverId': 'xxx', 'users[0].name': 'Alice' }
* @returns { jenkins: { serverId: 'xxx' }, users: [{ name: 'Alice' }] }
*
* @example
* const flatData = {
* 'jenkins.serverId': 'server-001',
* 'jenkins.url': 'http://jenkins.com',
* 'users[0].name': 'Alice',
* 'users[0].role': 'admin',
* 'description': '测试'
* };
*
* const nestedData = transformToNestedObject(flatData);
* // {
* // jenkins: { serverId: 'server-001', url: 'http://jenkins.com' },
* // users: [{ name: 'Alice', role: 'admin' }],
* // description: '测试'
* // }
*/
export function transformToNestedObject(formData: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
Object.keys(formData).forEach(fieldName => {
const value = formData[fieldName];
// 跳过 undefined 的值
if (value !== undefined) {
// lodash.set 自动处理所有路径格式
set(result, fieldName, value);
}
});
return result;
}
/**
*
* @param nestedData
* @param fieldNames
* @returns
*
* @example
* const nestedData = {
* jenkins: { serverId: 'server-001', url: 'http://jenkins.com' },
* users: [{ name: 'Alice', role: 'admin' }]
* };
*
* const fieldNames = [
* 'jenkins.serverId',
* 'jenkins.url',
* 'users[0].name',
* 'users[0].role'
* ];
*
* const flatData = transformToFlatObject(nestedData, fieldNames);
* // {
* // 'jenkins.serverId': 'server-001',
* // 'jenkins.url': 'http://jenkins.com',
* // 'users[0].name': 'Alice',
* // 'users[0].role': 'admin'
* // }
*/
export function transformToFlatObject(
nestedData: Record<string, any>,
fieldNames: string[]
): Record<string, any> {
const result: Record<string, any> = {};
fieldNames.forEach(fieldName => {
const value = get(nestedData, fieldName);
if (value !== undefined) {
result[fieldName] = value;
}
});
return result;
}
/**
*
* @param path
* @returns
*/
export function isNestedPath(path: string): boolean {
return path.includes('.') || path.includes('[');
}

View File

@ -17,12 +17,15 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
return validationRules.map(rule => {
const antdRule: Rule = {
type: rule.type === 'email' ? 'email' : rule.type === 'url' ? 'url' : undefined,
message: rule.message || `请输入正确的${rule.type}`,
message: rule.message || getDefaultMessage(rule.type),
// ✅ 保留用户配置的触发时机
validateTrigger: rule.trigger || 'blur',
};
switch (rule.type) {
case 'required':
antdRule.required = true;
antdRule.message = rule.message || '此项为必填项';
break;
case 'pattern':
if (rule.value) {
@ -39,15 +42,19 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
break;
case 'minLength':
antdRule.min = rule.value;
antdRule.message = rule.message || `最少输入${rule.value}个字符`;
break;
case 'maxLength':
antdRule.max = rule.value;
antdRule.message = rule.message || `最多输入${rule.value}个字符`;
break;
case 'phone':
antdRule.pattern = /^1[3-9]\d{9}$/;
antdRule.message = rule.message || '请输入正确的手机号格式';
break;
case 'idCard':
antdRule.pattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dX]$/;
antdRule.message = rule.message || '请输入正确的身份证号';
break;
}
@ -55,32 +62,63 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
});
};
/**
*
*/
function getDefaultMessage(type: string): string {
const messages: Record<string, string> = {
required: '此项为必填项',
email: '请输入正确的邮箱格式',
url: '请输入正确的URL格式',
phone: '请输入正确的手机号',
idCard: '请输入正确的身份证号',
pattern: '格式不正确',
min: '值太小',
max: '值太大',
minLength: '长度太短',
maxLength: '长度太长',
};
return messages[type] || '输入不正确';
}
/**
*
*
*
* 1. `required`
* 2.
* 3.
*
*
* -
* -
*
* @param field
* @param isRequired
* @returns
* @param isRequired
* @returns
*/
export const mergeValidationRules = (
field: { validationRules?: ValidationRule[]; required?: boolean; label?: string },
isRequired?: boolean
): Rule[] => {
// 1. 转换验证规则(格式、长度、范围等)
const customRules = convertValidationRules(field.validationRules);
// 检查自定义验证规则中是否已经包含必填验证
const hasRequiredRule = field.validationRules?.some(rule => rule.type === 'required');
// 基础规则:只有在没有自定义必填验证时,才使用字段属性中的"是否必填"
const baseRules: Rule[] = [];
// 2. 确定最终的必填状态
// 优先级:联动规则 required > 字段属性 required
const finalRequired = isRequired !== undefined ? isRequired : field.required;
if (finalRequired && !hasRequiredRule) {
baseRules.push({
// 3. 构建最终规则数组
if (finalRequired) {
// 需要必填:在最前面添加必填规则
const requiredRule: Rule = {
required: true,
message: `请输入${field.label}`,
});
message: `请输入${field.label || '内容'}`,
};
return [requiredRule, ...customRules]; // ✅ 必填在前
} else {
// 不需要必填:只返回其他验证规则
return customRules;
}
return [...baseRules, ...customRules];
};

View File

@ -99,7 +99,7 @@ const DialogBody = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex-1 overflow-y-auto px-6 py-4 pt-6",
"flex-1 overflow-y-auto px-6 py-4",
className
)}
{...props}
@ -128,7 +128,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
"text-lg font-semibold leading-none tracking-tight pb-4",
className
)}
{...props}

View File

@ -21,11 +21,12 @@ import {
import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils';
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
import { DeployConfirmDialog } from './DeployConfirmDialog';
import DeploymentFormModal from './DeploymentFormModal';
interface ApplicationCardProps {
app: ApplicationConfig;
environment: DeployEnvironment;
teamId: number;
onDeploy: (app: ApplicationConfig, remark: string) => void;
isDeploying: boolean;
}
@ -33,6 +34,7 @@ interface ApplicationCardProps {
export const ApplicationCard: React.FC<ApplicationCardProps> = ({
app,
environment,
teamId,
onDeploy,
isDeploying,
}) => {
@ -353,14 +355,17 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
)}
</Button>
{/* 部署确认对话框 */}
<DeployConfirmDialog
{/* 部署表单对话框 */}
<DeploymentFormModal
open={deployDialogOpen}
onOpenChange={setDeployDialogOpen}
onConfirm={(remark) => onDeploy(app, remark)}
applicationName={app.applicationName}
environmentName={environment.environmentName}
loading={isDeploying || app.isDeploying}
onClose={() => setDeployDialogOpen(false)}
app={app}
environment={environment}
teamId={teamId}
onSuccess={() => {
// 部署成功后,触发父组件的 onDeploy 回调(用于刷新数据)
onDeploy(app, '');
}}
/>
{/* 部署流程图模态框 */}

View File

@ -1,77 +1,210 @@
import React, { useRef } from 'react';
import React, {useRef, useEffect, useState} from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogBody,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { BetaSchemaForm } from '@ant-design/pro-components';
import { convertJsonSchemaToColumns } from '@/utils/jsonSchemaUtils';
import { message } from 'antd';
import type { DeploymentConfig } from '@/pages/Deploy/Deployment/List/types';
import { deployApp } from '../service';
import { DeployAppBuildDTO } from '../types';
import {Button} from "@/components/ui/button";
import {message} from 'antd';
import {Loader2} from 'lucide-react';
import {FormRenderer, type FormRendererRef, type FormSchema} from '@/components/FormDesigner';
import {getDefinitionById} from '@/pages/Form/Definition/List/service';
import {startDeployment} from '../service';
import type {ApplicationConfig, DeployEnvironment} from '../types';
interface DeploymentFormModalProps {
open: boolean;
onClose: () => void;
formSchema: any;
deployConfig: DeploymentConfig;
app: ApplicationConfig;
environment: DeployEnvironment;
teamId: number;
onSuccess?: () => void;
}
const DeploymentFormModal: React.FC<DeploymentFormModalProps> = ({
open,
onClose,
formSchema,
deployConfig
}) => {
const formRef = useRef<any>();
open,
onClose,
app,
environment,
teamId,
onSuccess
}) => {
const formRef = useRef<FormRendererRef>(null);
const [formSchema, setFormSchema] = useState<FormSchema | null>(null);
const [loading, setLoading] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const handleSubmit = async (values: any) => {
// ✅ 使用 useMemo 缓存预填充数据,避免每次渲染都创建新对象
const prefillData = React.useMemo(() => {
return {
// Jenkins 配置
jenkins: {
serverId: app.deploySystemId?.toString() || '',
jobName: app.deployJob || '',
branch: app.branch || 'master',
},
// 团队信息
teamId: teamId?.toString() || '',
// 应用信息
teamApplicationId: app.teamApplicationId?.toString() || '',
applicationId: app.applicationId?.toString() || '',
applicationCode: app.applicationCode || '',
applicationName: app.applicationName || '',
// 环境信息
environmentId: environment.environmentId?.toString() || '',
environmentCode: environment.environmentCode || '',
environmentName: environment.environmentName || '',
// 任务编号(自动生成)
taskNo: '',
// 审批信息
approval: {
required: environment.requiresApproval ? 'true' : 'false',
userIds: environment.approvers?.map(a => a.userId.toString()).join(',') || '',
},
// 通知信息(使用环境配置的通知设置)
notification: {
required: environment.notificationEnabled ? 'true' : 'false',
channelId: environment.notificationChannelId?.toString() || '',
},
};
}, [app, environment, teamId]); // 只在这些依赖变化时重新生成
// 🎯 1. 加载表单定义 (ID=2)
useEffect(() => {
if (open) {
loadFormSchema();
}
}, [open]);
const loadFormSchema = async () => {
try {
const deployData: DeployAppBuildDTO = {
buildType: deployConfig.buildType,
languageType: deployConfig.languageType,
formVariables: values,
buildVariables: deployConfig.buildVariables,
environmentId: deployConfig.environmentId,
applicationId: deployConfig.application.id,
workflowDefinitionId: deployConfig.publishedWorkflowDefinition?.id || 0
};
await deployApp(deployData);
message.success('部署任务已提交');
onClose();
setLoading(true);
const formDefinition = await getDefinitionById(2); // 固定表单ID=2
setFormSchema(formDefinition.schema);
} catch (error) {
message.error('部署失败:' + (error instanceof Error ? error.message : '未知错误'));
console.error('❌ 加载表单定义失败:', error);
message.error('加载表单失败');
} finally {
setLoading(false);
}
};
const columns = convertJsonSchemaToColumns(formSchema);
// 🎯 2. beforeSubmit 钩子:合并预填充数据
const handleBeforeSubmit = async (userInputData: Record<string, any>) => {
// 合并数据:用户输入优先级更高
const finalData = {
...prefillData, // 预填充数据(底层)
...userInputData, // 用户修改的数据(覆盖)
};
return finalData;
};
// 🎯 3. 提交到后端
const handleSubmit = async (formData: Record<string, any>) => {
try {
// 🔍 开发环境下打印完整数据用于调试
if (import.meta.env.DEV) {
console.group('🚀 部署请求数据');
console.log('完整数据结构:', JSON.stringify(formData, null, 2));
console.log('数据对象:', formData);
console.groupEnd();
}
// ✅ 使用完整的表单数据提交到后端
await startDeployment(formData);
message.success(
environment.requiresApproval
? '部署申请已提交,等待审批'
: '部署任务已创建'
);
return {success: true};
} catch (error: any) {
// ✅ 直接抛出原始错误,让 request.ts 拦截器统一处理错误提示
throw error;
}
};
// 🎯 4. 提交成功后的处理
const handleAfterSubmit = async () => {
onClose();
if (onSuccess) {
onSuccess();
}
};
// 🎯 5. 错误处理
const handleError = async (error: any) => {
// ✅ 不再显示错误提示,因为 request.ts 拦截器已经统一处理了
// 这里只做日志记录,避免重复提示
console.error('部署失败:', error);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[800px]">
<DialogHeader>
<DialogTitle> {deployConfig.application.appName}</DialogTitle>
<DialogTitle>
{app.applicationName} {environment.environmentName}
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="py-4">
<BetaSchemaForm
formRef={formRef}
layoutType="Form"
columns={columns}
onFinish={handleSubmit}
submitter={false}
/>
</div>
<DialogBody className="max-h-[60vh]">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground"/>
<span className="ml-2 text-muted-foreground">...</span>
</div>
) : formSchema ? (
<FormRenderer
ref={formRef}
schema={formSchema}
value={prefillData}
beforeSubmit={handleBeforeSubmit}
onSubmit={handleSubmit}
afterSubmit={handleAfterSubmit}
onError={handleError}
/>
) : (
<div className="text-center text-muted-foreground py-12">
</div>
)}
</DialogBody>
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={submitLoading}
>
</Button>
<Button onClick={() => formRef.current?.submit()}>
<Button
type="button"
disabled={submitLoading || loading}
onClick={async () => {
try {
setSubmitLoading(true);
await formRef.current?.submit();
} finally {
setSubmitLoading(false);
}
}}
>
{submitLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
{submitLoading ? '提交中...' : '确认部署'}
</Button>
</DialogFooter>
</DialogContent>
@ -79,4 +212,4 @@ const DeploymentFormModal: React.FC<DeploymentFormModalProps> = ({
);
};
export default DeploymentFormModal;
export default DeploymentFormModal;

View File

@ -15,7 +15,7 @@ import {
import { useToast } from '@/components/ui/use-toast';
import { useSelector } from 'react-redux';
import type { RootState } from '@/store';
import { getDeployEnvironments, startDeployment, getMyApprovalTasks } from './service';
import { getDeployEnvironments, getMyApprovalTasks } from './service';
import { ApplicationCard } from './components/ApplicationCard';
import { PendingApprovalModal } from './components/PendingApprovalModal';
import type { DeployTeam, ApplicationConfig } from './types';
@ -242,39 +242,11 @@ const Dashboard: React.FC = () => {
}
};
// 处理部署
// 处理部署成功后的回调(刷新数据)
// 注意:实际的部署提交已在 DeploymentFormModal 中完成
const handleDeploy = async (app: ApplicationConfig, remark: string) => {
if (!currentEnv) return;
// 立即显示部署中状态
setDeploying((prev) => new Set(prev).add(app.teamApplicationId));
try {
await startDeployment(app.teamApplicationId, remark);
toast({
title: currentEnv.requiresApproval ? '部署申请已提交' : '部署任务已创建',
description: currentEnv.requiresApproval
? '您的部署申请已提交,等待审批人审核'
: '部署任务已成功创建并开始执行',
});
// 接口成功后,保持部署中状态,等待自动刷新更新实际状态
// deploying 状态会在 loadData 中根据实际部署状态自动清除
} catch (error: any) {
// 接口失败时,立即清除部署中状态
setDeploying((prev) => {
const newSet = new Set(prev);
newSet.delete(app.teamApplicationId);
return newSet;
});
toast({
variant: 'destructive',
title: '操作失败',
description: error.response?.data?.message || '部署失败,请稍后重试',
});
}
// 部署成功后,刷新数据以获取最新状态
await loadData(false);
};
// 获取当前团队和环境
@ -462,6 +434,7 @@ const Dashboard: React.FC = () => {
key={app.teamApplicationId}
app={app}
environment={env}
teamId={currentTeam?.teamId || 0}
onDeploy={handleDeploy}
isDeploying={deploying.has(app.teamApplicationId)}
/>

View File

@ -11,9 +11,10 @@ export const getDeployEnvironments = () =>
/**
*
* @param deployData
*/
export const startDeployment = (teamApplicationId: number, remark?: string) =>
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId, remark });
export const startDeployment = (deployData: Record<string, any>) =>
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, deployData);
/**
*

View File

@ -65,6 +65,9 @@ export interface DeployEnvironment {
sort: number;
requiresApproval: boolean;
approvers: Approver[];
notificationEnabled: boolean; // 🆕 是否启用通知
notificationChannelId: number; // 🆕 通知渠道ID
requireCodeReview: boolean; // 🆕 是否需要代码审查
applications: ApplicationConfig[];
}
@ -72,7 +75,7 @@ export interface DeployTeam {
teamId: number;
teamCode: string;
teamName: string;
teamRole: string;
teamRole: string | null; // ✅ 可能为 null
description?: string;
environments: DeployEnvironment[];
}

View File

@ -64,6 +64,11 @@ const FormDesignerPage: React.FC = () => {
title: "保存成功",
description: `表单 "${formDefinition.name}" 已保存`,
});
// 🔄 刷新页面以获取最新的 version ID后端乐观锁版本号已更新
setTimeout(() => {
window.location.reload();
}, 500); // 延迟500ms让用户看到成功提示
} catch (error) {
console.error('保存表单失败:', error);
toast({

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
@ -8,8 +8,11 @@ import {
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { CheckCircle2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, FileText, Loader2 } from 'lucide-react';
import type { WorkflowDefinition } from '../types';
import { getDefinitionById as getFormDefinitionById } from '@/pages/Form/Definition/List/service';
import type { FormDefinitionResponse } from '@/pages/Form/Definition/List/types';
interface DeployDialogProps {
open: boolean;
@ -27,11 +30,30 @@ const DeployDialog: React.FC<DeployDialogProps> = ({
onOpenChange,
onConfirm,
}) => {
const [formDefinition, setFormDefinition] = useState<FormDefinitionResponse | null>(null);
const [loadingForm, setLoadingForm] = useState(false);
// 加载表单定义信息
useEffect(() => {
if (open && record?.formDefinitionId) {
setLoadingForm(true);
getFormDefinitionById(record.formDefinitionId)
.then(form => setFormDefinition(form))
.catch(err => {
console.error('加载表单定义失败:', err);
setFormDefinition(null);
})
.finally(() => setLoadingForm(false));
} else {
setFormDefinition(null);
}
}, [open, record?.formDefinitionId]);
if (!record) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
@ -41,11 +63,50 @@ const DeployDialog: React.FC<DeployDialogProps> = ({
</DialogDescription>
</DialogHeader>
{/* 工作流信息 */}
<div className="space-y-3 py-4 px-6 bg-muted/50 rounded-lg">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<code className="text-sm font-mono bg-background px-2 py-1 rounded">
{record.key}
</code>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"></span>
<Badge variant="outline">v{record.flowVersion}</Badge>
</div>
<div className="flex items-start justify-between gap-4">
<span className="text-sm text-muted-foreground shrink-0"></span>
<div className="flex items-center gap-2 flex-1 justify-end">
{loadingForm ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : formDefinition ? (
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-500" />
<span className="text-sm font-medium">{formDefinition.name}</span>
<Badge variant="outline" className="text-xs">
{formDefinition.key}
</Badge>
</div>
) : (
<span className="text-sm text-muted-foreground"></span>
)}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={onConfirm}></Button>
<Button onClick={onConfirm}>
<CheckCircle2 className="h-4 w-4 mr-2" />
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@ -129,6 +129,25 @@ const WorkflowDefinitionList: React.FC = () => {
// 发布
const handleDeploy = (record: WorkflowDefinition) => {
// ✅ 发布前强制校验:必须绑定启动表单
if (!record.formDefinitionId) {
toast({
title: '无法发布',
description: '工作流必须绑定启动表单后才能发布,请先编辑工作流并绑定表单',
variant: 'destructive',
action: (
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(record)}
>
</Button>
),
});
return;
}
setDeployRecord(record);
setDeployDialogOpen(true);
};

View File

@ -133,7 +133,7 @@ export const ApprovalNodeDefinition: ConfigurableNodeDefinition = {
type: "string",
title: "审批内容",
description: "审批任务的详细说明,支持变量",
'x-component': "textarea"
format: "textarea"
},
timeoutDuration: {
type: "number",

View File

@ -9,6 +9,7 @@ export interface NodeVariable {
nodeName: string; // 节点显示名称
fieldName: string; // 字段名
fieldType: string; // 字段类型
fieldDescription?: string; // 字段描述(用于补全提示)
displayText: string; // 界面显示格式: ${节点名称.字段名}
fullText: string; // 完整文本格式: 节点名称.字段名(用于搜索)
}
@ -100,6 +101,7 @@ export const collectNodeVariables = (
nodeName: nodeName,
fieldName: output.name,
fieldType: output.type,
fieldDescription: output.description, // ✅ 添加字段描述
displayText: `\${${nodeName}.outputs.${output.name}}`,
fullText: `${nodeName}.outputs.${output.name}`,
});