重构前端逻辑

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

View File

@ -81,6 +81,7 @@
"devDependencies": { "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",

View File

@ -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)

View File

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

View File

@ -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) {

View File

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

View File

@ -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);
} }
}; };

View File

@ -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,

View File

@ -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}

View File

@ -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 以支持栅格内字段的联动规则
/> />
); );
} }

View File

@ -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}>

View File

@ -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' }} />

View File

@ -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>

View File

@ -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: [[], []], // 初始化两列空数组
}, },
}, },
{ {

View File

@ -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;
}

View File

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

View File

@ -17,12 +17,15 @@ export const convertValidationRules = (validationRules?: ValidationRule[]): Rule
return validationRules.map(rule => { 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];
}; };

View File

@ -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}

View File

@ -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, '');
}}
/> />
{/* 部署流程图模态框 */} {/* 部署流程图模态框 */}

View File

@ -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>

View File

@ -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)}
/> />

View File

@ -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);
/** /**
* *

View File

@ -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[];
} }

View File

@ -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({

View File

@ -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>

View File

@ -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);
}; };

View File

@ -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",

View File

@ -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}`,
}); });