diff --git a/frontend/package.json b/frontend/package.json index 7c8c27d1..aa2f0a4d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 05d20041..7e60acb9 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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) diff --git a/frontend/src/components/CodeMirrorVariableInput/README.md b/frontend/src/components/CodeMirrorVariableInput/README.md index dc905592..cf9336a1 100644 --- a/frontend/src/components/CodeMirrorVariableInput/README.md +++ b/frontend/src/components/CodeMirrorVariableInput/README.md @@ -83,7 +83,7 @@ SelectOrVariableInput 已经集成了 CodeMirror,默认启用: 显示补全列表: - ${jenkins.buildNumber} - ${jenkins.buildUrl} - - ${form.applicationName} + - ${applicationName} - ... ``` diff --git a/frontend/src/components/CodeMirrorVariableInput/index.tsx b/frontend/src/components/CodeMirrorVariableInput/index.tsx index 42626fe2..b8c195a1 100644 --- a/frontend/src/components/CodeMirrorVariableInput/index.tsx +++ b/frontend/src/components/CodeMirrorVariableInput/index.tsx @@ -112,6 +112,9 @@ export const CodeMirrorVariableInput: React.FC = ( const onEditingChangeRef = useRef(onEditingChange); const editingTimeoutRef = useRef(null); + // ✅ 添加用户编辑状态标记(防止编辑时被外部 value 覆盖) + const isUserEditingRef = useRef(false); + // 同步 onChange 和 onEditingChange 引用 useEffect(() => { onChangeRef.current = onChange; @@ -120,6 +123,9 @@ export const CodeMirrorVariableInput: React.FC = ( // 智能值转换处理 const handleValueChange = (newValue: string) => { + // ✅ 标记用户正在编辑 + isUserEditingRef.current = true; + // 通知外部:用户开始编辑 onEditingChangeRef.current?.(true); @@ -128,9 +134,10 @@ export const CodeMirrorVariableInput: React.FC = ( 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 = ( // 收集所有可用变量 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 = ( 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 = ( }, [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) { diff --git a/frontend/src/components/CodeMirrorVariableInput/types.ts b/frontend/src/components/CodeMirrorVariableInput/types.ts index e2baacc5..99922633 100644 --- a/frontend/src/components/CodeMirrorVariableInput/types.ts +++ b/frontend/src/components/CodeMirrorVariableInput/types.ts @@ -36,7 +36,7 @@ export interface VariableInputProps { /** 当前节点ID(用于过滤前序节点)*/ currentNodeId: string; - /** 表单字段列表(用于支持 ${form.xxx} 变量)*/ + /** 表单字段列表(用于支持 ${xxx} 变量)*/ formFields?: FormField[]; /** 渲染类型 */ diff --git a/frontend/src/components/FormDesigner/Preview.tsx b/frontend/src/components/FormDesigner/Preview.tsx index 03575272..51aca458 100644 --- a/frontend/src/components/FormDesigner/Preview.tsx +++ b/frontend/src/components/FormDesigner/Preview.tsx @@ -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(({ 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); } }; diff --git a/frontend/src/components/FormDesigner/Renderer.tsx b/frontend/src/components/FormDesigner/Renderer.tsx index 9974af74..6bb48230 100644 --- a/frontend/src/components/FormDesigner/Renderer.tsx +++ b/frontend/src/components/FormDesigner/Renderer.tsx @@ -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((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((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((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((props, ref) setFormData({}); }; - // 监听表单值变化 - const handleValuesChange = (_: any, allValues: Record) => { - coreHandleValuesChange(_, allValues); // 调用核心 hook 的处理 - if (props.onChange) { - props.onChange(allValues); // 通知外部 - } - }; - // 暴露方法给父组件 useImperativeHandle(ref, () => ({ submit: handleSubmit, diff --git a/frontend/src/components/FormDesigner/components/FieldRenderer.tsx b/frontend/src/components/FormDesigner/components/FieldRenderer.tsx index f8e3887d..bedc3c97 100644 --- a/frontend/src/components/FormDesigner/components/FieldRenderer.tsx +++ b/frontend/src/components/FormDesigner/components/FieldRenderer.tsx @@ -89,11 +89,21 @@ const FieldRenderer: React.FC = ({ ); } + // ✅ 栅格布局:只在设计模式下渲染,运行时模式由 GridFieldPreview 处理 if (field.type === 'grid') { + // 预览/运行时模式:不在 FieldRenderer 层处理栅格 + // 因为 FormFieldsRenderer 已经使用 GridFieldPreview 处理了 + // 避免重复渲染和丢失 fieldStates + if (isPreview) { + console.warn('⚠️ FieldRenderer 在预览模式下不应处理 grid 类型,应由 GridFieldPreview 处理'); + return null; + } + + // 设计模式:使用 GridField(支持拖拽、选中等设计器功能) return ( = ({ fieldName }) => { return ( - + ); }; @@ -69,6 +69,7 @@ const FormFieldsRenderer: React.FC = ({ key={field.id} field={field} formConfig={formConfig} + fieldStates={fieldStates} // ✅ 传递 fieldStates 以支持栅格内字段的联动规则 /> ); } diff --git a/frontend/src/components/FormDesigner/components/GridFieldPreview.tsx b/frontend/src/components/FormDesigner/components/GridFieldPreview.tsx index 9acb4a83..b4a025a7 100644 --- a/frontend/src/components/FormDesigner/components/GridFieldPreview.tsx +++ b/frontend/src/components/FormDesigner/components/GridFieldPreview.tsx @@ -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 ( - + ); }; @@ -25,19 +27,21 @@ const HiddenField: React.FC<{ fieldName: string }> = ({ fieldName }) => { interface GridFieldPreviewProps { field: FieldConfig; formConfig?: FormConfig; + fieldStates?: Record; // ✅ 新增:支持联动规则状态 } const GridFieldPreview: React.FC = ({ 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 = ({ // 使用统一的字段分类器判断布局组件 const fieldType = FieldClassifier.classify(childField); - if (fieldType === 'layout' || fieldType === 'grid') { + + // ✅ 对于嵌套的栅格,递归调用 GridFieldPreview 而不是 FieldRenderer + // 这样可以正确传递 fieldStates,避免渲染空的容器 div + if (fieldType === 'grid') { + return ( + + ); + } + + // 其他布局组件(文本、分割线)正常渲染 + if (fieldType === 'layout') { return (
@@ -88,6 +107,26 @@ const GridFieldPreview: React.FC = ({ ); }; + // ✅ 检查栅格内是否有可见字段 + 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) => ( + + ))} + + ); + } + return (
diff --git a/frontend/src/components/FormDesigner/components/PropertyPanel.tsx b/frontend/src/components/FormDesigner/components/PropertyPanel.tsx index b5f8831c..c74fc46a 100644 --- a/frontend/src/components/FormDesigner/components/PropertyPanel.tsx +++ b/frontend/src/components/FormDesigner/components/PropertyPanel.tsx @@ -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 = ({ // 深度合并,特别处理 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 = ({ // 🔌 检查是否有自定义配置组件(插件式扩展) 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 (
= ({ ); } - 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 = ({ size="small" icon={} 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 = ({
- {(selectedField.columnSpans || [12, 12]).map((span, index) => ( + {(selectedField.columnSpans || GRID_DEFAULT_CONFIG.columnSpans).map((span, index) => ( {index + 1}. = ({ 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 = ({ icon={} 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 = ({ ))}
- 总宽度:{(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
diff --git a/frontend/src/components/FormDesigner/components/ValidationRuleEditor.tsx b/frontend/src/components/FormDesigner/components/ValidationRuleEditor.tsx index 734ffa07..5dde7195 100644 --- a/frontend/src/components/FormDesigner/components/ValidationRuleEditor.tsx +++ b/frontend/src/components/FormDesigner/components/ValidationRuleEditor.tsx @@ -17,25 +17,31 @@ interface ValidationRuleEditorProps { const ValidationRuleEditor: React.FC = ({ 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 = ({ value = [], value={rule.type} onChange={(val) => handleRuleChange(index, 'type', val)} > - 必填 正则表达式 最小值 最大值 diff --git a/frontend/src/components/FormDesigner/config.ts b/frontend/src/components/FormDesigner/config.ts index 0bf9bee6..5feed028 100644 --- a/frontend/src/components/FormDesigner/config.ts +++ b/frontend/src/components/FormDesigner/config.ts @@ -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(), // 初始化列数组 }, }, { diff --git a/frontend/src/components/FormDesigner/styles.css b/frontend/src/components/FormDesigner/styles.css index 4942a759..e2860e9b 100644 --- a/frontend/src/components/FormDesigner/styles.css +++ b/frontend/src/components/FormDesigner/styles.css @@ -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; +} diff --git a/frontend/src/components/FormDesigner/utils/pathHelper.ts b/frontend/src/components/FormDesigner/utils/pathHelper.ts new file mode 100644 index 00000000..89e61e41 --- /dev/null +++ b/frontend/src/components/FormDesigner/utils/pathHelper.ts @@ -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): Record { + const result: Record = {}; + + 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, + fieldNames: string[] +): Record { + const result: Record = {}; + + 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('['); +} + diff --git a/frontend/src/components/FormDesigner/utils/validationHelper.ts b/frontend/src/components/FormDesigner/utils/validationHelper.ts index 879cb677..64bda08c 100644 --- a/frontend/src/components/FormDesigner/utils/validationHelper.ts +++ b/frontend/src/components/FormDesigner/utils/validationHelper.ts @@ -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 = { + 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]; }; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 90301314..bd5af875 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -99,7 +99,7 @@ const DialogBody = ({ }: React.HTMLAttributes) => (
void; isDeploying: boolean; } @@ -33,6 +34,7 @@ interface ApplicationCardProps { export const ApplicationCard: React.FC = ({ app, environment, + teamId, onDeploy, isDeploying, }) => { @@ -353,14 +355,17 @@ export const ApplicationCard: React.FC = ({ )} - {/* 部署确认对话框 */} - 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, ''); + }} /> {/* 部署流程图模态框 */} diff --git a/frontend/src/pages/Dashboard/components/DeploymentFormModal.tsx b/frontend/src/pages/Dashboard/components/DeploymentFormModal.tsx index 4c9efa17..f14ca44f 100644 --- a/frontend/src/pages/Dashboard/components/DeploymentFormModal.tsx +++ b/frontend/src/pages/Dashboard/components/DeploymentFormModal.tsx @@ -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 = ({ - open, - onClose, - formSchema, - deployConfig -}) => { - const formRef = useRef(); + open, + onClose, + app, + environment, + teamId, + onSuccess + }) => { + const formRef = useRef(null); + const [formSchema, setFormSchema] = useState(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) => { + // 合并数据:用户输入优先级更高 + const finalData = { + ...prefillData, // 预填充数据(底层) + ...userInputData, // 用户修改的数据(覆盖) + }; + + return finalData; + }; + + // 🎯 3. 提交到后端 + const handleSubmit = async (formData: Record) => { + 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 ( - - + !isOpen && onClose()}> + - 部署 {deployConfig.application.appName} + + 部署 {app.applicationName} 到 {environment.environmentName} + + + 请填写部署配置信息并确认提交 + -
- -
+ + + {loading ? ( +
+ + 加载表单中... +
+ ) : formSchema ? ( + + ) : ( +
+ 表单加载失败,请稍后重试 +
+ )} +
+ - -
@@ -79,4 +212,4 @@ const DeploymentFormModal: React.FC = ({ ); }; -export default DeploymentFormModal; \ No newline at end of file +export default DeploymentFormModal; diff --git a/frontend/src/pages/Dashboard/index.tsx b/frontend/src/pages/Dashboard/index.tsx index 4bc8803f..29d6a839 100644 --- a/frontend/src/pages/Dashboard/index.tsx +++ b/frontend/src/pages/Dashboard/index.tsx @@ -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)} /> diff --git a/frontend/src/pages/Dashboard/service.ts b/frontend/src/pages/Dashboard/service.ts index 91427d73..c97121d2 100644 --- a/frontend/src/pages/Dashboard/service.ts +++ b/frontend/src/pages/Dashboard/service.ts @@ -11,9 +11,10 @@ export const getDeployEnvironments = () => /** * 发起部署 + * @param deployData 部署数据(包含表单填写的所有字段) */ -export const startDeployment = (teamApplicationId: number, remark?: string) => - request.post(`${DEPLOY_URL}/execute`, { teamApplicationId, remark }); +export const startDeployment = (deployData: Record) => + request.post(`${DEPLOY_URL}/execute`, deployData); /** * 获取部署流程图数据 diff --git a/frontend/src/pages/Dashboard/types.ts b/frontend/src/pages/Dashboard/types.ts index 92741e1e..3b1dd85f 100644 --- a/frontend/src/pages/Dashboard/types.ts +++ b/frontend/src/pages/Dashboard/types.ts @@ -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[]; } diff --git a/frontend/src/pages/Form/Definition/Designer/index.tsx b/frontend/src/pages/Form/Definition/Designer/index.tsx index 87f74038..6f76b7af 100644 --- a/frontend/src/pages/Form/Definition/Designer/index.tsx +++ b/frontend/src/pages/Form/Definition/Designer/index.tsx @@ -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({ diff --git a/frontend/src/pages/Workflow/Definition/List/components/DeployDialog.tsx b/frontend/src/pages/Workflow/Definition/List/components/DeployDialog.tsx index 406f6be9..dc7471b2 100644 --- a/frontend/src/pages/Workflow/Definition/List/components/DeployDialog.tsx +++ b/frontend/src/pages/Workflow/Definition/List/components/DeployDialog.tsx @@ -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 = ({ onOpenChange, onConfirm, }) => { + const [formDefinition, setFormDefinition] = useState(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 ( - + 确认发布工作流? @@ -41,11 +63,50 @@ const DeployDialog: React.FC = ({ 发布后将可以启动执行。 + + {/* 工作流信息 */} +
+
+ 流程标识: + + {record.key} + +
+
+ 流程版本: + v{record.flowVersion} +
+
+ 启动表单: +
+ {loadingForm ? ( +
+ + 加载中... +
+ ) : formDefinition ? ( +
+ + {formDefinition.name} + + {formDefinition.key} + +
+ ) : ( + 未绑定 + )} +
+
+
+ - +
diff --git a/frontend/src/pages/Workflow/Definition/List/index.tsx b/frontend/src/pages/Workflow/Definition/List/index.tsx index 7a30460f..c130c616 100644 --- a/frontend/src/pages/Workflow/Definition/List/index.tsx +++ b/frontend/src/pages/Workflow/Definition/List/index.tsx @@ -129,6 +129,25 @@ const WorkflowDefinitionList: React.FC = () => { // 发布 const handleDeploy = (record: WorkflowDefinition) => { + // ✅ 发布前强制校验:必须绑定启动表单 + if (!record.formDefinitionId) { + toast({ + title: '无法发布', + description: '工作流必须绑定启动表单后才能发布,请先编辑工作流并绑定表单', + variant: 'destructive', + action: ( + + ), + }); + return; + } + setDeployRecord(record); setDeployDialogOpen(true); }; diff --git a/frontend/src/pages/Workflow/Design/nodes/ApprovalNode.tsx b/frontend/src/pages/Workflow/Design/nodes/ApprovalNode.tsx index 0f0ac87a..ecad4db5 100644 --- a/frontend/src/pages/Workflow/Design/nodes/ApprovalNode.tsx +++ b/frontend/src/pages/Workflow/Design/nodes/ApprovalNode.tsx @@ -133,7 +133,7 @@ export const ApprovalNodeDefinition: ConfigurableNodeDefinition = { type: "string", title: "审批内容", description: "审批任务的详细说明,支持变量", - 'x-component': "textarea" + format: "textarea" }, timeoutDuration: { type: "number", diff --git a/frontend/src/utils/workflow/collectNodeVariables.ts b/frontend/src/utils/workflow/collectNodeVariables.ts index cf9a2ee7..e7aa5040 100644 --- a/frontend/src/utils/workflow/collectNodeVariables.ts +++ b/frontend/src/utils/workflow/collectNodeVariables.ts @@ -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}`, });