重构前端逻辑
This commit is contained in:
parent
6318ca6241
commit
0ab4472a2f
@ -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",
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -83,7 +83,7 @@ SelectOrVariableInput 已经集成了 CodeMirror,默认启用:
|
||||
显示补全列表:
|
||||
- ${jenkins.buildNumber}
|
||||
- ${jenkins.buildUrl}
|
||||
- ${form.applicationName}
|
||||
- ${applicationName}
|
||||
- ...
|
||||
```
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -36,7 +36,7 @@ export interface VariableInputProps {
|
||||
/** 当前节点ID(用于过滤前序节点)*/
|
||||
currentNodeId: string;
|
||||
|
||||
/** 表单字段列表(用于支持 ${form.xxx} 变量)*/
|
||||
/** 表单字段列表(用于支持 ${xxx} 变量)*/
|
||||
formFields?: FormField[];
|
||||
|
||||
/** 渲染类型 */
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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 以支持栅格内字段的联动规则
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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' }} />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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(), // 初始化列数组
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
102
frontend/src/components/FormDesigner/utils/pathHelper.ts
Normal file
102
frontend/src/components/FormDesigner/utils/pathHelper.ts
Normal 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('[');
|
||||
}
|
||||
|
||||
@ -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];
|
||||
};
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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, '');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 部署流程图模态框 */}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)}
|
||||
/>
|
||||
|
||||
@ -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);
|
||||
|
||||
/**
|
||||
* 获取部署流程图数据
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -133,7 +133,7 @@ export const ApprovalNodeDefinition: ConfigurableNodeDefinition = {
|
||||
type: "string",
|
||||
title: "审批内容",
|
||||
description: "审批任务的详细说明,支持变量",
|
||||
'x-component': "textarea"
|
||||
format: "textarea"
|
||||
},
|
||||
timeoutDuration: {
|
||||
type: "number",
|
||||
|
||||
@ -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}`,
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user