重构前端逻辑
This commit is contained in:
parent
6318ca6241
commit
0ab4472a2f
@ -81,6 +81,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dagre": "^0.7.52",
|
"@types/dagre": "^0.7.52",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
|
"@types/lodash": "^4.17.20",
|
||||||
"@types/node": "^20.17.10",
|
"@types/node": "^20.17.10",
|
||||||
"@types/react": "^18.3.18",
|
"@types/react": "^18.3.18",
|
||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
@ -95,6 +96,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.5",
|
"eslint-plugin-react-refresh": "^0.4.5",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
|||||||
@ -213,6 +213,9 @@ importers:
|
|||||||
'@types/fs-extra':
|
'@types/fs-extra':
|
||||||
specifier: ^11.0.4
|
specifier: ^11.0.4
|
||||||
version: 11.0.4
|
version: 11.0.4
|
||||||
|
'@types/lodash':
|
||||||
|
specifier: ^4.17.20
|
||||||
|
version: 4.17.20
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20.17.10
|
specifier: ^20.17.10
|
||||||
version: 20.17.10
|
version: 20.17.10
|
||||||
@ -255,6 +258,9 @@ importers:
|
|||||||
fs-extra:
|
fs-extra:
|
||||||
specifier: ^11.2.0
|
specifier: ^11.2.0
|
||||||
version: 11.2.0
|
version: 11.2.0
|
||||||
|
lodash:
|
||||||
|
specifier: ^4.17.21
|
||||||
|
version: 4.17.21
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.469.0
|
specifier: ^0.469.0
|
||||||
version: 0.469.0(react@18.3.1)
|
version: 0.469.0(react@18.3.1)
|
||||||
|
|||||||
@ -83,7 +83,7 @@ SelectOrVariableInput 已经集成了 CodeMirror,默认启用:
|
|||||||
显示补全列表:
|
显示补全列表:
|
||||||
- ${jenkins.buildNumber}
|
- ${jenkins.buildNumber}
|
||||||
- ${jenkins.buildUrl}
|
- ${jenkins.buildUrl}
|
||||||
- ${form.applicationName}
|
- ${applicationName}
|
||||||
- ...
|
- ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -112,6 +112,9 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
const onEditingChangeRef = useRef(onEditingChange);
|
const onEditingChangeRef = useRef(onEditingChange);
|
||||||
const editingTimeoutRef = useRef<number | null>(null);
|
const editingTimeoutRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// ✅ 添加用户编辑状态标记(防止编辑时被外部 value 覆盖)
|
||||||
|
const isUserEditingRef = useRef(false);
|
||||||
|
|
||||||
// 同步 onChange 和 onEditingChange 引用
|
// 同步 onChange 和 onEditingChange 引用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onChangeRef.current = onChange;
|
onChangeRef.current = onChange;
|
||||||
@ -120,6 +123,9 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
|
|
||||||
// 智能值转换处理
|
// 智能值转换处理
|
||||||
const handleValueChange = (newValue: string) => {
|
const handleValueChange = (newValue: string) => {
|
||||||
|
// ✅ 标记用户正在编辑
|
||||||
|
isUserEditingRef.current = true;
|
||||||
|
|
||||||
// 通知外部:用户开始编辑
|
// 通知外部:用户开始编辑
|
||||||
onEditingChangeRef.current?.(true);
|
onEditingChangeRef.current?.(true);
|
||||||
|
|
||||||
@ -128,9 +134,10 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
clearTimeout(editingTimeoutRef.current);
|
clearTimeout(editingTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 300ms 后通知编辑结束
|
// 300ms 后通知编辑结束并重置编辑状态
|
||||||
editingTimeoutRef.current = window.setTimeout(() => {
|
editingTimeoutRef.current = window.setTimeout(() => {
|
||||||
onEditingChangeRef.current?.(false);
|
onEditingChangeRef.current?.(false);
|
||||||
|
isUserEditingRef.current = false; // ✅ 重置编辑状态
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
// 值转换逻辑
|
// 值转换逻辑
|
||||||
@ -152,14 +159,15 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
|
|
||||||
// 收集所有可用变量
|
// 收集所有可用变量
|
||||||
const allVariables = React.useMemo(() => {
|
const allVariables = React.useMemo(() => {
|
||||||
// 表单字段变量
|
// 表单字段变量(去除 form. 前缀,直接使用原始字段名)
|
||||||
const formVariables = formFields.map(field => ({
|
const formVariables = formFields.map(field => ({
|
||||||
nodeId: 'form',
|
nodeId: 'form',
|
||||||
nodeName: '启动表单',
|
nodeName: '启动表单',
|
||||||
fieldName: field.name,
|
fieldName: field.name,
|
||||||
fieldType: field.type,
|
fieldType: field.type,
|
||||||
displayText: `\${form.${field.name}}`,
|
fieldDescription: field.label, // ✅ 使用表单字段的 label 作为描述
|
||||||
fullText: `form.${field.name}`,
|
displayText: `\${${field.name}}`,
|
||||||
|
fullText: `${field.name}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 节点变量
|
// 节点变量
|
||||||
@ -199,7 +207,8 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
options: filteredVariables.map(v => ({
|
options: filteredVariables.map(v => ({
|
||||||
label: v.displayText,
|
label: v.displayText,
|
||||||
type: 'variable',
|
type: 'variable',
|
||||||
info: `${v.nodeName} - ${v.fieldName}`,
|
// ✅ 优先显示字段描述,若无描述则显示"节点名 - 字段名"
|
||||||
|
info: v.fieldDescription || `${v.nodeName} - ${v.fieldName}`,
|
||||||
apply: v.displayText,
|
apply: v.displayText,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
@ -322,9 +331,15 @@ export const CodeMirrorVariableInput: React.FC<CodeMirrorVariableInputProps> = (
|
|||||||
}, [variant, rows, theme, disabled, placeholder, variableCompletion]);
|
}, [variant, rows, theme, disabled, placeholder, variableCompletion]);
|
||||||
|
|
||||||
// 当外部 value 变化时,更新编辑器内容
|
// 当外部 value 变化时,更新编辑器内容
|
||||||
|
// ✅ 添加保护:用户正在编辑时,不同步外部 value(防止删除时恢复)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!viewRef.current) return;
|
if (!viewRef.current) return;
|
||||||
|
|
||||||
|
// ⚠️ 如果用户正在编辑,跳过同步(防止删除操作被覆盖)
|
||||||
|
if (isUserEditingRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const stringValue = String(value || '');
|
const stringValue = String(value || '');
|
||||||
const currentValue = viewRef.current.state.doc.toString();
|
const currentValue = viewRef.current.state.doc.toString();
|
||||||
if (currentValue !== stringValue) {
|
if (currentValue !== stringValue) {
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export interface VariableInputProps {
|
|||||||
/** 当前节点ID(用于过滤前序节点)*/
|
/** 当前节点ID(用于过滤前序节点)*/
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
||||||
/** 表单字段列表(用于支持 ${form.xxx} 变量)*/
|
/** 表单字段列表(用于支持 ${xxx} 变量)*/
|
||||||
formFields?: FormField[];
|
formFields?: FormField[];
|
||||||
|
|
||||||
/** 渲染类型 */
|
/** 渲染类型 */
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import { Form, message } from 'antd';
|
|||||||
import type { FieldConfig, FormConfig } from './types';
|
import type { FieldConfig, FormConfig } from './types';
|
||||||
import { useFormCore } from './hooks/useFormCore';
|
import { useFormCore } from './hooks/useFormCore';
|
||||||
import { computeFormLayout } from './utils/formLayoutHelper';
|
import { computeFormLayout } from './utils/formLayoutHelper';
|
||||||
|
import { transformToNestedObject } from './utils/pathHelper';
|
||||||
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@ -63,16 +64,32 @@ const FormPreview = forwardRef<FormPreviewRef, FormPreviewProps>(({ fields, form
|
|||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
// 使用核心 hook 管理表单状态
|
// 使用核心 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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
const values = await form.validateFields();
|
const flatValues = await form.validateFields();
|
||||||
console.log('表单提交数据:', values);
|
console.log('📋 表单验证通过(扁平格式):', flatValues);
|
||||||
setFormData(values);
|
|
||||||
|
// 🎯 路径转换:将扁平数据转换为嵌套对象
|
||||||
|
const nestedValues = transformToNestedObject(flatValues);
|
||||||
|
console.log('🔄 转换为嵌套对象:', nestedValues);
|
||||||
|
|
||||||
|
setFormData(nestedValues);
|
||||||
message.success('表单提交成功!请查看控制台');
|
message.success('表单提交成功!请查看控制台');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('请填写必填项');
|
// ✅ 验证失败 - 不显示弹窗,让表单元素自己显示错误即可
|
||||||
|
console.error('❌ 表单验证失败:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,7 @@ import { COMPONENT_LIST } from './config';
|
|||||||
import { ComponentsContext } from './Designer';
|
import { ComponentsContext } from './Designer';
|
||||||
import { useFormCore } from './hooks/useFormCore';
|
import { useFormCore } from './hooks/useFormCore';
|
||||||
import { computeFormLayout } from './utils/formLayoutHelper';
|
import { computeFormLayout } from './utils/formLayoutHelper';
|
||||||
|
import { transformToNestedObject, transformToFlatObject } from './utils/pathHelper';
|
||||||
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
import FormFieldsRenderer from './components/FormFieldsRenderer';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
@ -131,29 +132,79 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
|||||||
// 使用核心 hook 管理表单状态
|
// 使用核心 hook 管理表单状态
|
||||||
const { setFormData, fieldStates, handleValuesChange: coreHandleValuesChange } = useFormCore({ fields, form });
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
try {
|
try {
|
||||||
// 1. 表单验证
|
// 1. 表单验证
|
||||||
const values = await form.validateFields();
|
const flatValues = await form.validateFields();
|
||||||
console.log('📋 表单验证通过:', values);
|
|
||||||
setFormData(values);
|
|
||||||
|
|
||||||
// 2. beforeSubmit 钩子 - 允许修改提交数据或中断提交
|
// 🎯 2. 路径转换:将扁平数据转换为嵌套对象
|
||||||
let finalValues = values;
|
// 字段名支持点号和数组索引,如 'jenkins.serverId' 或 'users[0].name'
|
||||||
|
const nestedValues = transformToNestedObject(flatValues);
|
||||||
|
|
||||||
|
setFormData(nestedValues);
|
||||||
|
|
||||||
|
// 3. beforeSubmit 钩子 - 允许修改提交数据或中断提交
|
||||||
|
let finalValues = nestedValues;
|
||||||
if (props.beforeSubmit) {
|
if (props.beforeSubmit) {
|
||||||
try {
|
try {
|
||||||
const result = await props.beforeSubmit(values);
|
const result = await props.beforeSubmit(nestedValues);
|
||||||
if (result === false) {
|
if (result === false) {
|
||||||
console.log('⚠️ beforeSubmit 返回 false,提交已取消');
|
|
||||||
message.warning('提交已取消');
|
message.warning('提交已取消');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result && typeof result === 'object') {
|
if (result && typeof result === 'object') {
|
||||||
finalValues = result;
|
finalValues = result;
|
||||||
console.log('🔄 beforeSubmit 修改了提交数据:', finalValues);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ beforeSubmit 钩子执行失败:', error);
|
console.error('❌ beforeSubmit 钩子失败:', error);
|
||||||
message.error('提交前处理失败');
|
message.error('提交前处理失败');
|
||||||
if (props.onError) {
|
if (props.onError) {
|
||||||
await props.onError(error);
|
await props.onError(error);
|
||||||
@ -167,7 +218,6 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
|||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
try {
|
try {
|
||||||
submitResult = await onSubmit(finalValues);
|
submitResult = await onSubmit(finalValues);
|
||||||
console.log('✅ 表单提交成功:', submitResult);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 表单提交失败:', error);
|
console.error('❌ 表单提交失败:', error);
|
||||||
// 提交失败时触发 onError 钩子
|
// 提交失败时触发 onError 钩子
|
||||||
@ -186,16 +236,15 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
|||||||
if (props.afterSubmit) {
|
if (props.afterSubmit) {
|
||||||
try {
|
try {
|
||||||
await props.afterSubmit(submitResult);
|
await props.afterSubmit(submitResult);
|
||||||
console.log('✅ afterSubmit 钩子执行完成');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('⚠️ afterSubmit 钩子执行失败:', error);
|
console.error('⚠️ afterSubmit 钩子失败:', error);
|
||||||
// afterSubmit 失败不影响整体提交流程,只记录日志
|
// afterSubmit 失败不影响整体提交流程,只记录日志
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 验证失败
|
// 验证失败 - 不显示弹窗,让表单元素自己显示错误即可
|
||||||
console.error('❌ 表单验证失败:', error);
|
console.error('❌ 表单验证/提交失败:', error);
|
||||||
message.error('请填写必填项');
|
// ✅ 移除 message.error(),错误信息已在表单元素上显示
|
||||||
|
|
||||||
// 触发 onError 钩子(验证失败也算错误)
|
// 触发 onError 钩子(验证失败也算错误)
|
||||||
if (props.onError) {
|
if (props.onError) {
|
||||||
@ -209,14 +258,6 @@ const FormRenderer = forwardRef<FormRendererRef, FormRendererProps>((props, ref)
|
|||||||
setFormData({});
|
setFormData({});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听表单值变化
|
|
||||||
const handleValuesChange = (_: any, allValues: Record<string, any>) => {
|
|
||||||
coreHandleValuesChange(_, allValues); // 调用核心 hook 的处理
|
|
||||||
if (props.onChange) {
|
|
||||||
props.onChange(allValues); // 通知外部
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 暴露方法给父组件
|
// 暴露方法给父组件
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
submit: handleSubmit,
|
submit: handleSubmit,
|
||||||
|
|||||||
@ -89,11 +89,21 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ 栅格布局:只在设计模式下渲染,运行时模式由 GridFieldPreview 处理
|
||||||
if (field.type === 'grid') {
|
if (field.type === 'grid') {
|
||||||
|
// 预览/运行时模式:不在 FieldRenderer 层处理栅格
|
||||||
|
// 因为 FormFieldsRenderer 已经使用 GridFieldPreview 处理了
|
||||||
|
// 避免重复渲染和丢失 fieldStates
|
||||||
|
if (isPreview) {
|
||||||
|
console.warn('⚠️ FieldRenderer 在预览模式下不应处理 grid 类型,应由 GridFieldPreview 处理');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设计模式:使用 GridField(支持拖拽、选中等设计器功能)
|
||||||
return (
|
return (
|
||||||
<GridField
|
<GridField
|
||||||
field={field}
|
field={field}
|
||||||
isPreview={isPreview}
|
isPreview={false}
|
||||||
selectedFieldId={selectedFieldId}
|
selectedFieldId={selectedFieldId}
|
||||||
onSelectField={onSelectField}
|
onSelectField={onSelectField}
|
||||||
onCopyField={onCopyField}
|
onCopyField={onCopyField}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Form } from 'antd';
|
import { Form, Input } from 'antd';
|
||||||
import type { FieldConfig, FormConfig } from '../types';
|
import type { FieldConfig, FormConfig } from '../types';
|
||||||
import type { FieldState } from '../utils/linkageHelper';
|
import type { FieldState } from '../utils/linkageHelper';
|
||||||
import { mergeValidationRules } from '../utils/validationHelper';
|
import { mergeValidationRules } from '../utils/validationHelper';
|
||||||
@ -19,7 +19,7 @@ import GridFieldPreview from './GridFieldPreview';
|
|||||||
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item name={fieldName} noStyle>
|
<Form.Item name={fieldName} noStyle>
|
||||||
<input type="hidden" />
|
<Input type="hidden" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -69,6 +69,7 @@ const FormFieldsRenderer: React.FC<FormFieldsRendererProps> = ({
|
|||||||
key={field.id}
|
key={field.id}
|
||||||
field={field}
|
field={field}
|
||||||
formConfig={formConfig}
|
formConfig={formConfig}
|
||||||
|
fieldStates={fieldStates} // ✅ 传递 fieldStates 以支持栅格内字段的联动规则
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,8 +4,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 { FieldConfig, FormConfig } from '../types';
|
||||||
|
import type { FieldState } from '../utils/linkageHelper';
|
||||||
import { mergeValidationRules } from '../utils/validationHelper';
|
import { mergeValidationRules } from '../utils/validationHelper';
|
||||||
import { computeFieldState, FieldClassifier } from '../utils/fieldStateHelper';
|
import { computeFieldState, FieldClassifier } from '../utils/fieldStateHelper';
|
||||||
import { computeGridColSpan } from '../utils/formLayoutHelper';
|
import { computeGridColSpan } from '../utils/formLayoutHelper';
|
||||||
@ -13,11 +14,12 @@ import FieldRenderer from './FieldRenderer';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 隐藏字段组件(内部使用)
|
* 隐藏字段组件(内部使用)
|
||||||
|
* 用于渲染隐藏字段(不显示任何 UI,但参与表单提交)
|
||||||
*/
|
*/
|
||||||
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
||||||
return (
|
return (
|
||||||
<Form.Item name={fieldName} noStyle>
|
<Form.Item name={fieldName} noStyle>
|
||||||
<input type="hidden" />
|
<Input type="hidden" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -25,19 +27,21 @@ const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => {
|
|||||||
interface GridFieldPreviewProps {
|
interface GridFieldPreviewProps {
|
||||||
field: FieldConfig;
|
field: FieldConfig;
|
||||||
formConfig?: FormConfig;
|
formConfig?: FormConfig;
|
||||||
|
fieldStates?: Record<string, FieldState>; // ✅ 新增:支持联动规则状态
|
||||||
}
|
}
|
||||||
|
|
||||||
const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||||
field,
|
field,
|
||||||
formConfig
|
formConfig,
|
||||||
|
fieldStates = {} // ✅ 默认值为空对象
|
||||||
}) => {
|
}) => {
|
||||||
const columns = field.columns || 2;
|
const columns = field.columns || 2;
|
||||||
const children = field.children || Array(columns).fill([]);
|
const children = field.children || Array(columns).fill([]);
|
||||||
|
|
||||||
const renderFieldItem = (childField: FieldConfig) => {
|
const renderFieldItem = (childField: FieldConfig) => {
|
||||||
// 计算字段最终状态(这里联动规则状态需要从外层传入,暂时只使用字段自身属性)
|
// ✅ 使用传入的 fieldStates 计算字段最终状态
|
||||||
// TODO: 如果需要支持栅格内的联动规则,需要将 fieldStates 作为 props 传入
|
const fieldState = fieldStates[childField.name] || {};
|
||||||
const computedState = computeFieldState(childField);
|
const computedState = computeFieldState(childField, fieldState);
|
||||||
|
|
||||||
// 如果字段被隐藏,使用隐藏字段组件
|
// 如果字段被隐藏,使用隐藏字段组件
|
||||||
if (!computedState.isVisible) {
|
if (!computedState.isVisible) {
|
||||||
@ -51,7 +55,22 @@ const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
|||||||
|
|
||||||
// 使用统一的字段分类器判断布局组件
|
// 使用统一的字段分类器判断布局组件
|
||||||
const fieldType = FieldClassifier.classify(childField);
|
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 (
|
return (
|
||||||
<div key={childField.id} style={{ marginBottom: 8 }}>
|
<div key={childField.id} style={{ marginBottom: 8 }}>
|
||||||
<FieldRenderer field={childField} isPreview={true} />
|
<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 (
|
return (
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<Row gutter={field.gutter || 16}>
|
<Row gutter={field.gutter || 16}>
|
||||||
|
|||||||
@ -24,6 +24,7 @@ import { DataSourceType, CascadeDataSourceType } from '@/domain/dataSource';
|
|||||||
import CascadeOptionEditor from './CascadeOptionEditor';
|
import CascadeOptionEditor from './CascadeOptionEditor';
|
||||||
import ValidationRuleEditor from './ValidationRuleEditor';
|
import ValidationRuleEditor from './ValidationRuleEditor';
|
||||||
import LinkageRuleEditor from './LinkageRuleEditor';
|
import LinkageRuleEditor from './LinkageRuleEditor';
|
||||||
|
import { GRID_DEFAULT_CONFIG, getDefaultGridChildren } from '../config';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@ -80,6 +81,16 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
// 深度合并,特别处理 apiDataSource 嵌套对象
|
// 深度合并,特别处理 apiDataSource 嵌套对象
|
||||||
let updatedField = { ...selectedField };
|
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) {
|
if ('dataSourceType' in changedValues) {
|
||||||
updatedField.dataSourceType = changedValues.dataSourceType;
|
updatedField.dataSourceType = changedValues.dataSourceType;
|
||||||
@ -187,20 +198,24 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
// 🔌 检查是否有自定义配置组件(插件式扩展)
|
// 🔌 检查是否有自定义配置组件(插件式扩展)
|
||||||
const componentMeta = allComponents.find(c => c.type === selectedField.type);
|
const componentMeta = allComponents.find(c => c.type === selectedField.type);
|
||||||
|
|
||||||
console.log('🔍 PropertyPanel Debug:', {
|
if (import.meta.env.DEV) {
|
||||||
selectedFieldType: selectedField.type,
|
console.log('🔍 PropertyPanel Debug:', {
|
||||||
allComponentsCount: allComponents.length,
|
selectedFieldType: selectedField.type,
|
||||||
allComponentTypes: allComponents.map(c => c.type),
|
allComponentsCount: allComponents.length,
|
||||||
componentMeta: componentMeta ? {
|
allComponentTypes: allComponents.map(c => c.type),
|
||||||
type: componentMeta.type,
|
componentMeta: componentMeta ? {
|
||||||
label: componentMeta.label,
|
type: componentMeta.type,
|
||||||
hasPropertyConfig: !!componentMeta.PropertyConfigComponent
|
label: componentMeta.label,
|
||||||
} : null
|
hasPropertyConfig: !!componentMeta.PropertyConfigComponent
|
||||||
});
|
} : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (componentMeta?.PropertyConfigComponent) {
|
if (componentMeta?.PropertyConfigComponent) {
|
||||||
const CustomConfig = componentMeta.PropertyConfigComponent;
|
const CustomConfig = componentMeta.PropertyConfigComponent;
|
||||||
console.log('✅ 使用自定义配置组件:', componentMeta.type);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('✅ 使用自定义配置组件:', componentMeta.type);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="form-property-panel-content">
|
<div className="form-property-panel-content">
|
||||||
<CustomConfig
|
<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 hasOptions = ['select', 'radio', 'checkbox'].includes(selectedField.type);
|
||||||
const isCascader = selectedField.type === 'cascader';
|
const isCascader = selectedField.type === 'cascader';
|
||||||
@ -384,12 +401,8 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 如果没有配置 columnSpans,先初始化为当前列数的平均分配
|
// 如果没有配置 columnSpans,使用默认配置
|
||||||
const currentSpans = selectedField.columnSpans || (() => {
|
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
|
||||||
const cols = selectedField.columns || 2;
|
|
||||||
const avgSpan = Math.floor(24 / cols);
|
|
||||||
return Array(cols).fill(avgSpan);
|
|
||||||
})();
|
|
||||||
|
|
||||||
const total = currentSpans.reduce((a, b) => a + b, 0);
|
const total = currentSpans.reduce((a, b) => a + b, 0);
|
||||||
if (total >= 24) {
|
if (total >= 24) {
|
||||||
@ -417,7 +430,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
<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">
|
<Space key={index} style={{ display: 'flex', width: '100%' }} align="center">
|
||||||
<Text style={{ minWidth: 20 }}>{index + 1}.</Text>
|
<Text style={{ minWidth: 20 }}>{index + 1}.</Text>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
@ -426,7 +439,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
value={span}
|
value={span}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (value) {
|
if (value) {
|
||||||
const currentSpans = selectedField.columnSpans || [12, 12];
|
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
|
||||||
const newSpans = [...currentSpans];
|
const newSpans = [...currentSpans];
|
||||||
newSpans[index] = value;
|
newSpans[index] = value;
|
||||||
onFieldChange({
|
onFieldChange({
|
||||||
@ -442,17 +455,19 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
icon={<DeleteOutlined />}
|
icon={<DeleteOutlined />}
|
||||||
danger
|
danger
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentSpans = selectedField.columnSpans || [12, 12];
|
const currentSpans = selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans;
|
||||||
const newSpans = currentSpans.filter((_, i) => i !== index);
|
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) {
|
if (newSpans.length === 0) {
|
||||||
onFieldChange({
|
onFieldChange({
|
||||||
...selectedField,
|
...selectedField,
|
||||||
columnSpans: [12, 12],
|
columns: GRID_DEFAULT_CONFIG.columns,
|
||||||
columns: 2,
|
columnSpans: [...GRID_DEFAULT_CONFIG.columnSpans], // 创建新数组
|
||||||
children: [[], []],
|
gutter: GRID_DEFAULT_CONFIG.gutter,
|
||||||
|
children: getDefaultGridChildren(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
onFieldChange({
|
onFieldChange({
|
||||||
@ -468,7 +483,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
|||||||
))}
|
))}
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: 8, fontSize: 12, color: '#8c8c8c' }}>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Divider style={{ margin: '16px 0' }} />
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
|||||||
@ -17,25 +17,31 @@ interface ValidationRuleEditorProps {
|
|||||||
const ValidationRuleEditor: React.FC<ValidationRuleEditorProps> = ({ value = [], onChange }) => {
|
const ValidationRuleEditor: React.FC<ValidationRuleEditorProps> = ({ value = [], onChange }) => {
|
||||||
const handleAddRule = () => {
|
const handleAddRule = () => {
|
||||||
const newRule: ValidationRule = {
|
const newRule: ValidationRule = {
|
||||||
type: 'required',
|
type: 'pattern', // 默认为正则表达式
|
||||||
message: '',
|
message: '',
|
||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
};
|
};
|
||||||
const newRules = [...value, newRule];
|
const newRules = [...value, newRule];
|
||||||
console.log('➕ [ValidationRuleEditor] 添加验证规则:', newRules);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('➕ [ValidationRuleEditor] 添加验证规则:', newRules);
|
||||||
|
}
|
||||||
onChange?.(newRules);
|
onChange?.(newRules);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteRule = (index: number) => {
|
const handleDeleteRule = (index: number) => {
|
||||||
const newRules = value.filter((_, i) => i !== index);
|
const newRules = value.filter((_, i) => i !== index);
|
||||||
console.log('🗑️ [ValidationRuleEditor] 删除验证规则:', newRules);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log('🗑️ [ValidationRuleEditor] 删除验证规则:', newRules);
|
||||||
|
}
|
||||||
onChange?.(newRules);
|
onChange?.(newRules);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRuleChange = (index: number, field: keyof ValidationRule, fieldValue: any) => {
|
const handleRuleChange = (index: number, field: keyof ValidationRule, fieldValue: any) => {
|
||||||
const newRules = [...value];
|
const newRules = [...value];
|
||||||
newRules[index] = { ...newRules[index], [field]: fieldValue };
|
newRules[index] = { ...newRules[index], [field]: fieldValue };
|
||||||
console.log(`✏️ [ValidationRuleEditor] 修改验证规则 [${field}]:`, newRules);
|
if (import.meta.env.DEV) {
|
||||||
|
console.log(`✏️ [ValidationRuleEditor] 修改验证规则 [${field}]:`, newRules);
|
||||||
|
}
|
||||||
onChange?.(newRules);
|
onChange?.(newRules);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -125,7 +131,6 @@ const ValidationRuleEditor: React.FC<ValidationRuleEditorProps> = ({ value = [],
|
|||||||
value={rule.type}
|
value={rule.type}
|
||||||
onChange={(val) => handleRuleChange(index, 'type', val)}
|
onChange={(val) => handleRuleChange(index, 'type', val)}
|
||||||
>
|
>
|
||||||
<Select.Option value="required">必填</Select.Option>
|
|
||||||
<Select.Option value="pattern">正则表达式</Select.Option>
|
<Select.Option value="pattern">正则表达式</Select.Option>
|
||||||
<Select.Option value="min">最小值</Select.Option>
|
<Select.Option value="min">最小值</Select.Option>
|
||||||
<Select.Option value="max">最大值</Select.Option>
|
<Select.Option value="max">最大值</Select.Option>
|
||||||
|
|||||||
@ -3,6 +3,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 {
|
import {
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
@ -65,10 +77,8 @@ export const COMPONENT_LIST: ComponentMeta[] = [
|
|||||||
icon: BorderOutlined,
|
icon: BorderOutlined,
|
||||||
category: '布局字段',
|
category: '布局字段',
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
columns: 2,
|
...GRID_DEFAULT_CONFIG,
|
||||||
columnSpans: [12, 12], // 默认两列,各占12格
|
children: getDefaultGridChildren(), // 初始化列数组
|
||||||
gutter: 16,
|
|
||||||
children: [[], []], // 初始化两列空数组
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
/**
|
/*
|
||||||
* 表单设计器样式
|
* 表单设计器样式
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -36,7 +36,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: #fafafa;
|
background: #fff;
|
||||||
border-right: 1px solid #e8e8e8;
|
border-right: 1px solid #e8e8e8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,17 +64,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 8px 6px;
|
gap: 6px;
|
||||||
background: #fff;
|
padding: 10px 8px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
border-radius: 6px;
|
border-radius: 4px;
|
||||||
transition: all 0.3s ease;
|
|
||||||
user-select: none;
|
|
||||||
cursor: move;
|
cursor: move;
|
||||||
font-size: 12px;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
background: #fff;
|
||||||
overflow: hidden;
|
user-select: none;
|
||||||
text-overflow: ellipsis;
|
font-size: 13px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,9 +113,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 400px;
|
height: 100%;
|
||||||
border: 2px dashed #d9d9d9;
|
color: #8c8c8c;
|
||||||
border-radius: 8px;
|
font-size: 14px;
|
||||||
background: #fafafa;
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,14 +129,14 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 8px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
margin-bottom: 20px;
|
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 2px solid #e8e8e8;
|
border: 1px solid #e8e8e8;
|
||||||
border-radius: 8px;
|
border-radius: 4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 栅格布局字段项特殊样式 - 更大的间距 */
|
/* 栅格布局字段项特殊样式 - 更大的间距 */
|
||||||
@ -193,104 +191,85 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 确保所有表单组件高度一致 */
|
/* 属性面板 */
|
||||||
.ant-form-item .ant-input:not(textarea),
|
.form-designer-property-panel {
|
||||||
.ant-form-item .ant-input-number,
|
width: 320px;
|
||||||
.ant-form-item .ant-input-number-input-wrap,
|
background: #fff;
|
||||||
.ant-form-item .ant-select-selector,
|
border-left: 1px solid #e8e8e8;
|
||||||
.ant-form-item .ant-picker {
|
overflow-y: auto;
|
||||||
min-height: 32px !important;
|
flex-shrink: 0;
|
||||||
height: 32px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* textarea 使用 auto 高度以支持多行 */
|
/* 辅助类 */
|
||||||
.ant-form-item textarea.ant-input {
|
.flex-center {
|
||||||
height: auto !important;
|
|
||||||
min-height: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-form-item .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
|
|
||||||
height: 32px !important;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 11px !important;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-item .ant-select-selection-search-input {
|
.flex-between {
|
||||||
height: 30px !important;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-item .ant-input-number-input {
|
.cursor-pointer {
|
||||||
height: 30px !important;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 中等尺寸 */
|
.cursor-move {
|
||||||
.ant-form-middle .ant-input:not(textarea),
|
cursor: move;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-middle textarea.ant-input {
|
/* 栅格拖拽占位符 */
|
||||||
height: auto !important;
|
.form-designer-grid-drop-zone {
|
||||||
min-height: auto !important;
|
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 {
|
.form-designer-grid-drop-zone.active {
|
||||||
height: 32px !important;
|
border-color: #1890ff;
|
||||||
|
background: #f0f5ff;
|
||||||
|
color: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 大尺寸 */
|
/* 栅格列的拖拽区域 */
|
||||||
.ant-form-large .ant-input:not(textarea),
|
.form-designer-grid-column {
|
||||||
.ant-form-large .ant-input-number,
|
min-height: 80px;
|
||||||
.ant-form-large .ant-input-number-input-wrap,
|
position: relative;
|
||||||
.ant-form-large .ant-select-selector,
|
|
||||||
.ant-form-large .ant-picker {
|
|
||||||
min-height: 40px !important;
|
|
||||||
height: 40px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-large textarea.ant-input {
|
/* 栅格列内的字段项 - 移除外部间距,让栅格的 gutter 控制间距 */
|
||||||
height: auto !important;
|
.form-designer-grid-column .form-designer-field-item {
|
||||||
min-height: auto !important;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-large .ant-select-single:not(.ant-select-customize-input) .ant-select-selector {
|
.form-designer-grid-column .form-designer-field-item:last-child {
|
||||||
height: 40px !important;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 小尺寸 */
|
/* 栅格的拖拽提示 */
|
||||||
.ant-form-small .ant-input:not(textarea),
|
.form-designer-grid-hint {
|
||||||
.ant-form-small .ant-input-number,
|
padding: 8px;
|
||||||
.ant-form-small .ant-input-number-input-wrap,
|
text-align: center;
|
||||||
.ant-form-small .ant-select-selector,
|
color: #8c8c8c;
|
||||||
.ant-form-small .ant-picker {
|
font-size: 12px;
|
||||||
min-height: 24px !important;
|
border: 1px dashed #d9d9d9;
|
||||||
height: 24px !important;
|
border-radius: 4px;
|
||||||
|
background: #fafafa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ant-form-small textarea.ant-input {
|
/* === 紧凑模式 === */
|
||||||
height: auto !important;
|
/* 通用样式 */
|
||||||
min-height: auto !important;
|
.form-container-padding {
|
||||||
}
|
padding: 24px;
|
||||||
|
|
||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 表单渲染器和预览器的紧凑间距样式 */
|
/* 表单渲染器和预览器的紧凑间距样式 */
|
||||||
@ -363,20 +342,10 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 只读字段样式:灰色背景,不可编辑 */
|
/* === 只读状态样式 === */
|
||||||
.ant-input[readonly],
|
/* 对于 readonly 字段,使用灰色背景并禁用光标 */
|
||||||
.ant-input:read-only {
|
input.ant-input[readonly],
|
||||||
background-color: #f5f5f5 !important;
|
input.ant-input:read-only,
|
||||||
cursor: not-allowed !important;
|
|
||||||
color: #00000040 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-input-number-disabled {
|
|
||||||
background-color: #f5f5f5 !important;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
color: #00000040 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea.ant-input[readonly],
|
textarea.ant-input[readonly],
|
||||||
textarea.ant-input:read-only {
|
textarea.ant-input:read-only {
|
||||||
background-color: #f5f5f5 !important;
|
background-color: #f5f5f5 !important;
|
||||||
@ -384,3 +353,24 @@ textarea.ant-input:read-only {
|
|||||||
color: #00000040 !important;
|
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 => {
|
return validationRules.map(rule => {
|
||||||
const antdRule: Rule = {
|
const antdRule: Rule = {
|
||||||
type: rule.type === 'email' ? 'email' : rule.type === 'url' ? 'url' : undefined,
|
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) {
|
switch (rule.type) {
|
||||||
case 'required':
|
case 'required':
|
||||||
antdRule.required = true;
|
antdRule.required = true;
|
||||||
|
antdRule.message = rule.message || '此项为必填项';
|
||||||
break;
|
break;
|
||||||
case 'pattern':
|
case 'pattern':
|
||||||
if (rule.value) {
|
if (rule.value) {
|
||||||
@ -39,15 +42,19 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
|
|||||||
break;
|
break;
|
||||||
case 'minLength':
|
case 'minLength':
|
||||||
antdRule.min = rule.value;
|
antdRule.min = rule.value;
|
||||||
|
antdRule.message = rule.message || `最少输入${rule.value}个字符`;
|
||||||
break;
|
break;
|
||||||
case 'maxLength':
|
case 'maxLength':
|
||||||
antdRule.max = rule.value;
|
antdRule.max = rule.value;
|
||||||
|
antdRule.message = rule.message || `最多输入${rule.value}个字符`;
|
||||||
break;
|
break;
|
||||||
case 'phone':
|
case 'phone':
|
||||||
antdRule.pattern = /^1[3-9]\d{9}$/;
|
antdRule.pattern = /^1[3-9]\d{9}$/;
|
||||||
|
antdRule.message = rule.message || '请输入正确的手机号格式';
|
||||||
break;
|
break;
|
||||||
case 'idCard':
|
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.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;
|
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 field 字段配置
|
||||||
* @param isRequired 是否必填(来自联动规则)
|
* @param isRequired 是否必填(来自联动规则,动态覆盖字段属性)
|
||||||
* @returns 合并后的验证规则
|
* @returns 合并后的验证规则数组
|
||||||
*/
|
*/
|
||||||
export const mergeValidationRules = (
|
export const mergeValidationRules = (
|
||||||
field: { validationRules?: ValidationRule[]; required?: boolean; label?: string },
|
field: { validationRules?: ValidationRule[]; required?: boolean; label?: string },
|
||||||
isRequired?: boolean
|
isRequired?: boolean
|
||||||
): Rule[] => {
|
): Rule[] => {
|
||||||
|
// 1. 转换验证规则(格式、长度、范围等)
|
||||||
const customRules = convertValidationRules(field.validationRules);
|
const customRules = convertValidationRules(field.validationRules);
|
||||||
|
|
||||||
// 检查自定义验证规则中是否已经包含必填验证
|
// 2. 确定最终的必填状态
|
||||||
const hasRequiredRule = field.validationRules?.some(rule => rule.type === 'required');
|
// 优先级:联动规则 required > 字段属性 required
|
||||||
|
|
||||||
// 基础规则:只有在没有自定义必填验证时,才使用字段属性中的"是否必填"
|
|
||||||
const baseRules: Rule[] = [];
|
|
||||||
const finalRequired = isRequired !== undefined ? isRequired : field.required;
|
const finalRequired = isRequired !== undefined ? isRequired : field.required;
|
||||||
|
|
||||||
if (finalRequired && !hasRequiredRule) {
|
// 3. 构建最终规则数组
|
||||||
baseRules.push({
|
if (finalRequired) {
|
||||||
|
// 需要必填:在最前面添加必填规则
|
||||||
|
const requiredRule: Rule = {
|
||||||
required: true,
|
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>) => (
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 overflow-y-auto px-6 py-4 pt-6",
|
"flex-1 overflow-y-auto px-6 py-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@ -128,7 +128,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-lg font-semibold leading-none tracking-tight",
|
"text-lg font-semibold leading-none tracking-tight pb-4",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -21,11 +21,12 @@ import {
|
|||||||
import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils';
|
import { formatDuration, formatTime, getStatusIcon, getStatusText } from '../utils/dashboardUtils';
|
||||||
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
|
import type { ApplicationConfig, DeployEnvironment, DeployRecord } from '../types';
|
||||||
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
|
import { DeployFlowGraphModal } from './DeployFlowGraphModal';
|
||||||
import { DeployConfirmDialog } from './DeployConfirmDialog';
|
import DeploymentFormModal from './DeploymentFormModal';
|
||||||
|
|
||||||
interface ApplicationCardProps {
|
interface ApplicationCardProps {
|
||||||
app: ApplicationConfig;
|
app: ApplicationConfig;
|
||||||
environment: DeployEnvironment;
|
environment: DeployEnvironment;
|
||||||
|
teamId: number;
|
||||||
onDeploy: (app: ApplicationConfig, remark: string) => void;
|
onDeploy: (app: ApplicationConfig, remark: string) => void;
|
||||||
isDeploying: boolean;
|
isDeploying: boolean;
|
||||||
}
|
}
|
||||||
@ -33,6 +34,7 @@ interface ApplicationCardProps {
|
|||||||
export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
||||||
app,
|
app,
|
||||||
environment,
|
environment,
|
||||||
|
teamId,
|
||||||
onDeploy,
|
onDeploy,
|
||||||
isDeploying,
|
isDeploying,
|
||||||
}) => {
|
}) => {
|
||||||
@ -353,14 +355,17 @@ export const ApplicationCard: React.FC<ApplicationCardProps> = ({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 部署确认对话框 */}
|
{/* 部署表单对话框 */}
|
||||||
<DeployConfirmDialog
|
<DeploymentFormModal
|
||||||
open={deployDialogOpen}
|
open={deployDialogOpen}
|
||||||
onOpenChange={setDeployDialogOpen}
|
onClose={() => setDeployDialogOpen(false)}
|
||||||
onConfirm={(remark) => onDeploy(app, remark)}
|
app={app}
|
||||||
applicationName={app.applicationName}
|
environment={environment}
|
||||||
environmentName={environment.environmentName}
|
teamId={teamId}
|
||||||
loading={isDeploying || app.isDeploying}
|
onSuccess={() => {
|
||||||
|
// 部署成功后,触发父组件的 onDeploy 回调(用于刷新数据)
|
||||||
|
onDeploy(app, '');
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 部署流程图模态框 */}
|
{/* 部署流程图模态框 */}
|
||||||
|
|||||||
@ -1,77 +1,210 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, {useRef, useEffect, useState} from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogBody,
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import {Button} from "@/components/ui/button";
|
||||||
import { BetaSchemaForm } from '@ant-design/pro-components';
|
import {message} from 'antd';
|
||||||
import { convertJsonSchemaToColumns } from '@/utils/jsonSchemaUtils';
|
import {Loader2} from 'lucide-react';
|
||||||
import { message } from 'antd';
|
import {FormRenderer, type FormRendererRef, type FormSchema} from '@/components/FormDesigner';
|
||||||
import type { DeploymentConfig } from '@/pages/Deploy/Deployment/List/types';
|
import {getDefinitionById} from '@/pages/Form/Definition/List/service';
|
||||||
import { deployApp } from '../service';
|
import {startDeployment} from '../service';
|
||||||
import { DeployAppBuildDTO } from '../types';
|
import type {ApplicationConfig, DeployEnvironment} from '../types';
|
||||||
|
|
||||||
interface DeploymentFormModalProps {
|
interface DeploymentFormModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
formSchema: any;
|
app: ApplicationConfig;
|
||||||
deployConfig: DeploymentConfig;
|
environment: DeployEnvironment;
|
||||||
|
teamId: number;
|
||||||
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DeploymentFormModal: React.FC<DeploymentFormModalProps> = ({
|
const DeploymentFormModal: React.FC<DeploymentFormModalProps> = ({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
formSchema,
|
app,
|
||||||
deployConfig
|
environment,
|
||||||
}) => {
|
teamId,
|
||||||
const formRef = useRef<any>();
|
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 {
|
try {
|
||||||
const deployData: DeployAppBuildDTO = {
|
setLoading(true);
|
||||||
buildType: deployConfig.buildType,
|
const formDefinition = await getDefinitionById(2); // 固定表单ID=2
|
||||||
languageType: deployConfig.languageType,
|
setFormSchema(formDefinition.schema);
|
||||||
formVariables: values,
|
|
||||||
buildVariables: deployConfig.buildVariables,
|
|
||||||
environmentId: deployConfig.environmentId,
|
|
||||||
applicationId: deployConfig.application.id,
|
|
||||||
workflowDefinitionId: deployConfig.publishedWorkflowDefinition?.id || 0
|
|
||||||
};
|
|
||||||
|
|
||||||
await deployApp(deployData);
|
|
||||||
message.success('部署任务已提交');
|
|
||||||
onClose();
|
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||||
<DialogContent className="sm:max-w-[600px]">
|
<DialogContent className="sm:max-w-[800px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>部署 {deployConfig.application.appName}</DialogTitle>
|
<DialogTitle>
|
||||||
|
部署 {app.applicationName} 到 {environment.environmentName}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请填写部署配置信息并确认提交
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="py-4">
|
|
||||||
<BetaSchemaForm
|
<DialogBody className="max-h-[60vh]">
|
||||||
formRef={formRef}
|
{loading ? (
|
||||||
layoutType="Form"
|
<div className="flex items-center justify-center py-12">
|
||||||
columns={columns}
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground"/>
|
||||||
onFinish={handleSubmit}
|
<span className="ml-2 text-muted-foreground">加载表单中...</span>
|
||||||
submitter={false}
|
</div>
|
||||||
/>
|
) : formSchema ? (
|
||||||
</div>
|
<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>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitLoading}
|
||||||
|
>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</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>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</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 { useToast } from '@/components/ui/use-toast';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import type { RootState } from '@/store';
|
import type { RootState } from '@/store';
|
||||||
import { getDeployEnvironments, startDeployment, getMyApprovalTasks } from './service';
|
import { getDeployEnvironments, getMyApprovalTasks } from './service';
|
||||||
import { ApplicationCard } from './components/ApplicationCard';
|
import { ApplicationCard } from './components/ApplicationCard';
|
||||||
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
import { PendingApprovalModal } from './components/PendingApprovalModal';
|
||||||
import type { DeployTeam, ApplicationConfig } from './types';
|
import type { DeployTeam, ApplicationConfig } from './types';
|
||||||
@ -242,39 +242,11 @@ const Dashboard: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理部署
|
// 处理部署成功后的回调(刷新数据)
|
||||||
|
// 注意:实际的部署提交已在 DeploymentFormModal 中完成
|
||||||
const handleDeploy = async (app: ApplicationConfig, remark: string) => {
|
const handleDeploy = async (app: ApplicationConfig, remark: string) => {
|
||||||
if (!currentEnv) return;
|
// 部署成功后,刷新数据以获取最新状态
|
||||||
|
await loadData(false);
|
||||||
// 立即显示部署中状态
|
|
||||||
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 || '部署失败,请稍后重试',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 获取当前团队和环境
|
// 获取当前团队和环境
|
||||||
@ -462,6 +434,7 @@ const Dashboard: React.FC = () => {
|
|||||||
key={app.teamApplicationId}
|
key={app.teamApplicationId}
|
||||||
app={app}
|
app={app}
|
||||||
environment={env}
|
environment={env}
|
||||||
|
teamId={currentTeam?.teamId || 0}
|
||||||
onDeploy={handleDeploy}
|
onDeploy={handleDeploy}
|
||||||
isDeploying={deploying.has(app.teamApplicationId)}
|
isDeploying={deploying.has(app.teamApplicationId)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -11,9 +11,10 @@ export const getDeployEnvironments = () =>
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发起部署
|
* 发起部署
|
||||||
|
* @param deployData 部署数据(包含表单填写的所有字段)
|
||||||
*/
|
*/
|
||||||
export const startDeployment = (teamApplicationId: number, remark?: string) =>
|
export const startDeployment = (deployData: Record<string, any>) =>
|
||||||
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, { teamApplicationId, remark });
|
request.post<StartDeploymentResponse>(`${DEPLOY_URL}/execute`, deployData);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取部署流程图数据
|
* 获取部署流程图数据
|
||||||
|
|||||||
@ -65,6 +65,9 @@ export interface DeployEnvironment {
|
|||||||
sort: number;
|
sort: number;
|
||||||
requiresApproval: boolean;
|
requiresApproval: boolean;
|
||||||
approvers: Approver[];
|
approvers: Approver[];
|
||||||
|
notificationEnabled: boolean; // 🆕 是否启用通知
|
||||||
|
notificationChannelId: number; // 🆕 通知渠道ID
|
||||||
|
requireCodeReview: boolean; // 🆕 是否需要代码审查
|
||||||
applications: ApplicationConfig[];
|
applications: ApplicationConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,7 +75,7 @@ export interface DeployTeam {
|
|||||||
teamId: number;
|
teamId: number;
|
||||||
teamCode: string;
|
teamCode: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamRole: string;
|
teamRole: string | null; // ✅ 可能为 null
|
||||||
description?: string;
|
description?: string;
|
||||||
environments: DeployEnvironment[];
|
environments: DeployEnvironment[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,6 +64,11 @@ const FormDesignerPage: React.FC = () => {
|
|||||||
title: "保存成功",
|
title: "保存成功",
|
||||||
description: `表单 "${formDefinition.name}" 已保存`,
|
description: `表单 "${formDefinition.name}" 已保存`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔄 刷新页面以获取最新的 version ID(后端乐观锁版本号已更新)
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 500); // 延迟500ms让用户看到成功提示
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存表单失败:', error);
|
console.error('保存表单失败:', error);
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -8,8 +8,11 @@ import {
|
|||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
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 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 {
|
interface DeployDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -27,11 +30,30 @@ const DeployDialog: React.FC<DeployDialogProps> = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onConfirm,
|
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;
|
if (!record) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent>
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||||
<CheckCircle2 className="h-5 w-5" /> 确认发布工作流?
|
<CheckCircle2 className="h-5 w-5" /> 确认发布工作流?
|
||||||
@ -41,11 +63,50 @@ const DeployDialog: React.FC<DeployDialogProps> = ({
|
|||||||
发布后将可以启动执行。
|
发布后将可以启动执行。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onConfirm}>确认发布</Button>
|
<Button onClick={onConfirm}>
|
||||||
|
<CheckCircle2 className="h-4 w-4 mr-2" />
|
||||||
|
确认发布
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@ -129,6 +129,25 @@ const WorkflowDefinitionList: React.FC = () => {
|
|||||||
|
|
||||||
// 发布
|
// 发布
|
||||||
const handleDeploy = (record: WorkflowDefinition) => {
|
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);
|
setDeployRecord(record);
|
||||||
setDeployDialogOpen(true);
|
setDeployDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -133,7 +133,7 @@ export const ApprovalNodeDefinition: ConfigurableNodeDefinition = {
|
|||||||
type: "string",
|
type: "string",
|
||||||
title: "审批内容",
|
title: "审批内容",
|
||||||
description: "审批任务的详细说明,支持变量",
|
description: "审批任务的详细说明,支持变量",
|
||||||
'x-component': "textarea"
|
format: "textarea"
|
||||||
},
|
},
|
||||||
timeoutDuration: {
|
timeoutDuration: {
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export interface NodeVariable {
|
|||||||
nodeName: string; // 节点显示名称
|
nodeName: string; // 节点显示名称
|
||||||
fieldName: string; // 字段名
|
fieldName: string; // 字段名
|
||||||
fieldType: string; // 字段类型
|
fieldType: string; // 字段类型
|
||||||
|
fieldDescription?: string; // 字段描述(用于补全提示)
|
||||||
displayText: string; // 界面显示格式: ${节点名称.字段名}
|
displayText: string; // 界面显示格式: ${节点名称.字段名}
|
||||||
fullText: string; // 完整文本格式: 节点名称.字段名(用于搜索)
|
fullText: string; // 完整文本格式: 节点名称.字段名(用于搜索)
|
||||||
}
|
}
|
||||||
@ -100,6 +101,7 @@ export const collectNodeVariables = (
|
|||||||
nodeName: nodeName,
|
nodeName: nodeName,
|
||||||
fieldName: output.name,
|
fieldName: output.name,
|
||||||
fieldType: output.type,
|
fieldType: output.type,
|
||||||
|
fieldDescription: output.description, // ✅ 添加字段描述
|
||||||
displayText: `\${${nodeName}.outputs.${output.name}}`,
|
displayText: `\${${nodeName}.outputs.${output.name}}`,
|
||||||
fullText: `${nodeName}.outputs.${output.name}`,
|
fullText: `${nodeName}.outputs.${output.name}`,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user