|
|
@@ -32,7 +32,8 @@ import {
|
|
|
Cascader,
|
|
|
Upload,
|
|
|
Dropdown,
|
|
|
- Alert
|
|
|
+ Alert,
|
|
|
+ Tag
|
|
|
} from 'antd';
|
|
|
import type { MenuProps } from 'antd';
|
|
|
import { UploadOutlined, DownSquareOutlined, CheckSquareOutlined, ClockCircleOutlined } from '@ant-design/icons';
|
|
|
@@ -166,6 +167,10 @@ interface FormField {
|
|
|
alertShowIcon?: boolean;
|
|
|
alertCentered?: boolean;
|
|
|
alertTheme?: 'light' | 'dark';
|
|
|
+ // 容器相关属性
|
|
|
+ children?: FormField[]; // 子字段(用于grid、card、tabs等容器组件)
|
|
|
+ gridColumns?: 1 | 2 | 3; // 栅格布局的列数
|
|
|
+ cardTitle?: string; // 卡片标题
|
|
|
}
|
|
|
|
|
|
// 表单配置接口
|
|
|
@@ -180,6 +185,8 @@ interface FormConfig {
|
|
|
inlineValidation?: boolean; // 以行内形式展示校验信息
|
|
|
showSubmitButton?: boolean; // 是否显示表单提交按钮
|
|
|
showResetButton?: boolean; // 是否显示表单重置按钮
|
|
|
+ layoutColumns?: 1 | 2 | 3; // 表单布局列数
|
|
|
+ useCardLayout?: boolean; // 是否使用外层卡片包装
|
|
|
}
|
|
|
|
|
|
// 组件库配置
|
|
|
@@ -188,6 +195,7 @@ type ComponentItem = {
|
|
|
label: string;
|
|
|
icon: any;
|
|
|
uploadType?: 'single-image' | 'multiple-image' | 'file';
|
|
|
+ layoutColumns?: number; // 针对布局组件的列数
|
|
|
};
|
|
|
|
|
|
const componentLibrary: Record<string, ComponentItem[]> = {
|
|
|
@@ -213,12 +221,12 @@ const componentLibrary: Record<string, ComponentItem[]> = {
|
|
|
// { type: 'rate', label: '评分器', icon: Hash },
|
|
|
// { type: 'treeselect', label: '树选择', icon: DownSquareOutlined },
|
|
|
],
|
|
|
- // '布局组件': [
|
|
|
- // { type: 'card', label: '卡片', icon: Layout },
|
|
|
- // { type: 'grid', label: '网格布局', icon: Grid },
|
|
|
- // { type: 'tabs', label: '选项卡', icon: FileText },
|
|
|
- // { type: 'collapse', label: '折叠面板', icon: FileText },
|
|
|
- // ],
|
|
|
+ '布局组件': [
|
|
|
+ { type: 'card', label: '卡片容器', icon: Layout },
|
|
|
+ { type: 'grid', label: '双栏布局', icon: Grid, layoutColumns: 2 },
|
|
|
+ { type: 'grid', label: '三栏布局', icon: Grid, layoutColumns: 3 },
|
|
|
+ { type: 'tabs', label: '选项卡', icon: FileText },
|
|
|
+ ],
|
|
|
};
|
|
|
|
|
|
// 可拖拽的组件项
|
|
|
@@ -227,17 +235,18 @@ const DraggableComponent: React.FC<{
|
|
|
label: string;
|
|
|
icon: any;
|
|
|
uploadType?: 'single-image' | 'multiple-image' | 'file';
|
|
|
- onDrag: (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file') => void;
|
|
|
-}> = ({ type, label, icon: Icon, uploadType, onDrag }) => {
|
|
|
+ layoutColumns?: number;
|
|
|
+ onDrag: (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file', layoutColumns?: number, parentId?: string) => void;
|
|
|
+}> = ({ type, label, icon: Icon, uploadType, layoutColumns, onDrag }) => {
|
|
|
const [{ isDragging }, drag] = useDrag({
|
|
|
type: 'component',
|
|
|
- item: { type, uploadType },
|
|
|
+ item: { type, uploadType, layoutColumns },
|
|
|
collect: (monitor) => ({
|
|
|
isDragging: monitor.isDragging(),
|
|
|
}),
|
|
|
end: (item, monitor) => {
|
|
|
if (monitor.didDrop()) {
|
|
|
- onDrag(item.type, item.uploadType);
|
|
|
+ onDrag(item.type, item.uploadType, item.layoutColumns);
|
|
|
}
|
|
|
},
|
|
|
});
|
|
|
@@ -260,12 +269,15 @@ const DraggableComponent: React.FC<{
|
|
|
// 可放置的画布区域
|
|
|
const CanvasDropZone: React.FC<{
|
|
|
children: React.ReactNode;
|
|
|
- onDrop: (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file') => void;
|
|
|
-}> = ({ children, onDrop }) => {
|
|
|
+ onDrop: (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file', layoutColumns?: number, parentId?: string) => void;
|
|
|
+ parentId?: string;
|
|
|
+ className?: string;
|
|
|
+ style?: React.CSSProperties;
|
|
|
+}> = ({ children, onDrop, parentId, className = '', style }) => {
|
|
|
const [{ isOver }, drop] = useDrop({
|
|
|
accept: 'component',
|
|
|
- drop: (item: { type: FieldType; uploadType?: 'single-image' | 'multiple-image' | 'file' }) => {
|
|
|
- onDrop(item.type, item.uploadType);
|
|
|
+ drop: (item: { type: FieldType; uploadType?: 'single-image' | 'multiple-image' | 'file'; layoutColumns?: number }) => {
|
|
|
+ onDrop(item.type, item.uploadType, item.layoutColumns, parentId);
|
|
|
},
|
|
|
collect: (monitor) => ({
|
|
|
isOver: monitor.isOver(),
|
|
|
@@ -275,23 +287,42 @@ const CanvasDropZone: React.FC<{
|
|
|
return (
|
|
|
<div
|
|
|
ref={drop}
|
|
|
- className={`h-full p-8 transition-colors ${
|
|
|
- isOver ? 'bg-blue-50 border-2 border-blue-300 border-dashed' : 'bg-white'
|
|
|
+ className={`min-h-[80px] transition-colors ${className} ${
|
|
|
+ isOver ? 'bg-blue-50 border-2 border-blue-300 border-dashed rounded' : ''
|
|
|
}`}
|
|
|
+ style={style}
|
|
|
>
|
|
|
{children}
|
|
|
</div>
|
|
|
);
|
|
|
};
|
|
|
|
|
|
-// 字段渲染组件
|
|
|
+// 字段渲染组件(支持容器和嵌套)
|
|
|
const FieldRenderer: React.FC<{
|
|
|
field: FormField;
|
|
|
isSelected: boolean;
|
|
|
onSelect: () => void;
|
|
|
onDelete: () => void;
|
|
|
+ onAddFieldToContainer?: (parentId: string, type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file', layoutColumns?: number) => void;
|
|
|
+ onUpdateField?: (fieldId: string, updates: Partial<FormField>) => void;
|
|
|
+ onDeleteField?: (fieldId: string) => void;
|
|
|
+ onSelectField?: (fieldId: string) => void;
|
|
|
formConfig?: FormConfig;
|
|
|
-}> = ({ field, isSelected, onSelect, onDelete, formConfig }) => {
|
|
|
+ selectedFieldId?: string | null;
|
|
|
+ allFields?: FormField[];
|
|
|
+}> = ({
|
|
|
+ field,
|
|
|
+ isSelected,
|
|
|
+ onSelect,
|
|
|
+ onDelete,
|
|
|
+ onAddFieldToContainer,
|
|
|
+ onUpdateField,
|
|
|
+ onDeleteField,
|
|
|
+ onSelectField,
|
|
|
+ formConfig,
|
|
|
+ selectedFieldId,
|
|
|
+ allFields = []
|
|
|
+}) => {
|
|
|
const renderField = () => {
|
|
|
const baseProps = {
|
|
|
disabled: field.disabled,
|
|
|
@@ -443,15 +474,6 @@ const FieldRenderer: React.FC<{
|
|
|
}
|
|
|
};
|
|
|
|
|
|
- // 计算标签样式
|
|
|
- const labelWidth = field.labelWidth || formConfig?.labelWidth || 100;
|
|
|
- const labelStyle: React.CSSProperties = {
|
|
|
- width: `${labelWidth}px`,
|
|
|
- textAlign: formConfig?.labelPosition === 'left' ? 'left' : formConfig?.labelPosition === 'right' ? 'right' : 'left',
|
|
|
- display: 'inline-block',
|
|
|
- marginRight: formConfig?.labelPosition === 'top' ? 0 : '8px',
|
|
|
- verticalAlign: formConfig?.labelPosition === 'top' ? 'top' : 'middle',
|
|
|
- };
|
|
|
|
|
|
const fieldContainerStyle: React.CSSProperties = {
|
|
|
marginTop: field.margin?.top,
|
|
|
@@ -461,6 +483,175 @@ const FieldRenderer: React.FC<{
|
|
|
padding: field.padding ? `${field.padding.top || 0}px ${field.padding.right || 0}px ${field.padding.bottom || 0}px ${field.padding.left || 0}px` : undefined,
|
|
|
};
|
|
|
|
|
|
+ // 如果是容器组件(grid或card),渲染容器及其子字段
|
|
|
+ if (field.type === 'grid') {
|
|
|
+ const gridColumns = field.gridColumns || 2;
|
|
|
+ const children = field.children || [];
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`relative border-2 rounded-lg transition-all ${
|
|
|
+ isSelected
|
|
|
+ ? 'border-blue-500 bg-blue-50 shadow-md'
|
|
|
+ : 'border-dashed border-gray-300 hover:border-blue-300 hover:bg-gray-50'
|
|
|
+ }`}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onSelect();
|
|
|
+ }}
|
|
|
+ style={fieldContainerStyle}
|
|
|
+ >
|
|
|
+ <div className="p-2 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
|
|
+ <span className="text-xs text-gray-600">{field.label || `${gridColumns}栏布局`}</span>
|
|
|
+ {isSelected && (
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<Trash2 className="w-3 h-3" />}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onDelete();
|
|
|
+ }}
|
|
|
+ danger
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="p-4">
|
|
|
+ <CanvasDropZone
|
|
|
+ onDrop={(type, uploadType, layoutCols) => {
|
|
|
+ if (onAddFieldToContainer) {
|
|
|
+ onAddFieldToContainer(field.id, type, uploadType, layoutCols);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ parentId={field.id}
|
|
|
+ className="min-h-[100px]"
|
|
|
+ style={{ minHeight: '100px', width: '100%' }}
|
|
|
+ >
|
|
|
+ <div
|
|
|
+ style={{
|
|
|
+ display: 'grid',
|
|
|
+ gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
|
|
+ gap: '12px',
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {children.length === 0 ? (
|
|
|
+ Array.from({ length: gridColumns }).map((_, idx) => (
|
|
|
+ <div
|
|
|
+ key={idx}
|
|
|
+ className="border-2 border-dashed border-gray-300 rounded p-4 min-h-[80px] flex items-center justify-center text-gray-400 text-xs"
|
|
|
+ >
|
|
|
+ 拖拽组件到第 {idx + 1} 列
|
|
|
+ </div>
|
|
|
+ ))
|
|
|
+ ) : (
|
|
|
+ children.map((childField) => (
|
|
|
+ <div
|
|
|
+ key={childField.id}
|
|
|
+ style={
|
|
|
+ gridColumns > 1
|
|
|
+ ? { gridColumn: `span ${Math.min(gridColumns, childField.span || 1)}` }
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <FieldRenderer
|
|
|
+ field={childField}
|
|
|
+ isSelected={selectedFieldId === childField.id}
|
|
|
+ onSelect={() => onSelectField?.(childField.id)}
|
|
|
+ onDelete={() => onDeleteField?.(childField.id)}
|
|
|
+ onAddFieldToContainer={onAddFieldToContainer}
|
|
|
+ onUpdateField={onUpdateField}
|
|
|
+ onDeleteField={onDeleteField}
|
|
|
+ onSelectField={onSelectField}
|
|
|
+ formConfig={formConfig}
|
|
|
+ selectedFieldId={selectedFieldId}
|
|
|
+ allFields={allFields}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </CanvasDropZone>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ if (field.type === 'card') {
|
|
|
+ const children = field.children || [];
|
|
|
+ const cardTitle = field.cardTitle || field.label || '卡片容器';
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`relative border-2 rounded-lg transition-all ${
|
|
|
+ isSelected
|
|
|
+ ? 'border-blue-500 bg-blue-50 shadow-md'
|
|
|
+ : 'border-dashed border-gray-300 hover:border-blue-300 hover:bg-gray-50'
|
|
|
+ }`}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onSelect();
|
|
|
+ }}
|
|
|
+ style={fieldContainerStyle}
|
|
|
+ >
|
|
|
+ <div className="p-2 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
|
|
+ <span className="text-xs text-gray-600 font-medium">{cardTitle}</span>
|
|
|
+ {isSelected && (
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<Trash2 className="w-3 h-3" />}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onDelete();
|
|
|
+ }}
|
|
|
+ danger
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ <div className="p-4">
|
|
|
+ <CanvasDropZone
|
|
|
+ onDrop={(type, uploadType, layoutCols) => {
|
|
|
+ if (onAddFieldToContainer) {
|
|
|
+ onAddFieldToContainer(field.id, type, uploadType, layoutCols);
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ parentId={field.id}
|
|
|
+ className="min-h-[100px]"
|
|
|
+ style={{ minHeight: '100px', width: '100%' }}
|
|
|
+ >
|
|
|
+ {children.length === 0 ? (
|
|
|
+ <div className="border-2 border-dashed border-gray-300 rounded p-8 min-h-[100px] flex items-center justify-center text-gray-400 text-xs">
|
|
|
+ 拖拽组件到此卡片容器内
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {children.map((childField) => (
|
|
|
+ <FieldRenderer
|
|
|
+ key={childField.id}
|
|
|
+ field={childField}
|
|
|
+ isSelected={selectedFieldId === childField.id}
|
|
|
+ onSelect={() => onSelectField?.(childField.id)}
|
|
|
+ onDelete={() => onDeleteField?.(childField.id)}
|
|
|
+ onAddFieldToContainer={onAddFieldToContainer}
|
|
|
+ onUpdateField={onUpdateField}
|
|
|
+ onDeleteField={onDeleteField}
|
|
|
+ onSelectField={onSelectField}
|
|
|
+ formConfig={formConfig}
|
|
|
+ selectedFieldId={selectedFieldId}
|
|
|
+ allFields={allFields}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </CanvasDropZone>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 普通字段的渲染
|
|
|
+ const labelWidth = field.labelWidth || formConfig?.labelWidth || 100;
|
|
|
+
|
|
|
return (
|
|
|
<div
|
|
|
className={`relative p-4 border-2 rounded-lg transition-all cursor-pointer ${
|
|
|
@@ -468,7 +659,10 @@ const FieldRenderer: React.FC<{
|
|
|
? 'border-blue-500 bg-blue-50 shadow-md'
|
|
|
: 'border-transparent hover:border-gray-300 hover:bg-gray-50'
|
|
|
}`}
|
|
|
- onClick={onSelect}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onSelect();
|
|
|
+ }}
|
|
|
style={fieldContainerStyle}
|
|
|
>
|
|
|
{field.type === 'alert' ? (
|
|
|
@@ -673,6 +867,8 @@ export default function FormDesigner() {
|
|
|
inlineValidation: false,
|
|
|
showSubmitButton: true, // 预览默认显示提交按钮
|
|
|
showResetButton: false,
|
|
|
+ layoutColumns: 1,
|
|
|
+ useCardLayout: true,
|
|
|
};
|
|
|
|
|
|
const [formConfig, setFormConfig] = useState<FormConfig>(defaultFormConfig);
|
|
|
@@ -708,6 +904,17 @@ export default function FormDesigner() {
|
|
|
try {
|
|
|
const data = await FormApi.getForm(id);
|
|
|
|
|
|
+ // ========== 打印回显使用的原始数据 ==========
|
|
|
+ console.log('========== 表单回显 - 原始接口数据 ==========');
|
|
|
+ console.log('接口返回的完整数据 (data):', JSON.stringify(data, null, 2));
|
|
|
+ console.log('表单ID (data.id):', data.id);
|
|
|
+ console.log('表单名称 (data.name):', data.name);
|
|
|
+ console.log('表单备注 (data.remark):', data.remark);
|
|
|
+ console.log('表单状态 (data.status):', data.status);
|
|
|
+ console.log('表单配置字符串 (data.conf):', data.conf);
|
|
|
+ console.log('字段列表字符串数组 (data.fields):', data.fields);
|
|
|
+ console.log('==========================================');
|
|
|
+
|
|
|
// 如果是复制表单,清除 id 并修改表单名称
|
|
|
if (formType === 'copy') {
|
|
|
setFormName((data.name || '') + '_copy');
|
|
|
@@ -727,18 +934,25 @@ export default function FormDesigner() {
|
|
|
? JSON.parse(data.fields)
|
|
|
: data.fields;
|
|
|
|
|
|
+ console.log('========== 表单回显 - 字段数据解析 ==========');
|
|
|
+ console.log('解析后的字段数据 (fieldsData):', JSON.stringify(fieldsData, null, 2));
|
|
|
+
|
|
|
if (Array.isArray(fieldsData)) {
|
|
|
parsedFields = fieldsData.map((field: any, index: number) => {
|
|
|
let parsedField = field;
|
|
|
if (typeof field === 'string') {
|
|
|
try {
|
|
|
parsedField = JSON.parse(field);
|
|
|
+ console.log(`字段 ${index} - 从字符串解析:`, parsedField);
|
|
|
} catch (err) {
|
|
|
console.error('单个字段解析失败:', err);
|
|
|
parsedField = {};
|
|
|
}
|
|
|
+ } else {
|
|
|
+ console.log(`字段 ${index} - 直接使用对象:`, parsedField);
|
|
|
}
|
|
|
- return {
|
|
|
+
|
|
|
+ const mappedField = {
|
|
|
id: parsedField.id || `field_${Date.now()}_${index}`, // 复制时生成新的 id
|
|
|
type: parsedField.type || 'input',
|
|
|
label: parsedField.label || parsedField.title || '',
|
|
|
@@ -780,8 +994,16 @@ export default function FormDesigner() {
|
|
|
alertCentered: parsedField.alertCentered,
|
|
|
alertTheme: parsedField.alertTheme,
|
|
|
};
|
|
|
+
|
|
|
+ console.log(`字段 ${index} - 映射后的字段对象:`, mappedField);
|
|
|
+ return mappedField;
|
|
|
});
|
|
|
}
|
|
|
+
|
|
|
+ console.log('========== 表单回显 - 最终字段列表 ==========');
|
|
|
+ console.log('解析后的所有字段 (parsedFields):', JSON.stringify(parsedFields, null, 2));
|
|
|
+ console.log('字段总数:', parsedFields.length);
|
|
|
+ console.log('==========================================');
|
|
|
} catch (e) {
|
|
|
console.error('解析字段数据失败:', e);
|
|
|
}
|
|
|
@@ -793,8 +1015,16 @@ export default function FormDesigner() {
|
|
|
if (data.conf) {
|
|
|
try {
|
|
|
const confData = typeof data.conf === 'string' ? JSON.parse(data.conf) : data.conf;
|
|
|
+
|
|
|
+ console.log('========== 表单回显 - 表单配置解析 ==========');
|
|
|
+ console.log('解析后的配置数据 (confData):', JSON.stringify(confData, null, 2));
|
|
|
+
|
|
|
if (confData.formConfig) {
|
|
|
- setFormConfig({ ...defaultFormConfig, ...confData.formConfig });
|
|
|
+ const mergedConfig = { ...defaultFormConfig, ...confData.formConfig };
|
|
|
+ console.log('默认表单配置 (defaultFormConfig):', JSON.stringify(defaultFormConfig, null, 2));
|
|
|
+ console.log('合并后的表单配置 (mergedConfig):', JSON.stringify(mergedConfig, null, 2));
|
|
|
+ setFormConfig(mergedConfig);
|
|
|
+
|
|
|
// 如果表单配置中有名称且当前表单名称为空,则使用配置中的名称
|
|
|
if (confData.formConfig.name && !formName) {
|
|
|
if (formType === 'copy') {
|
|
|
@@ -804,17 +1034,77 @@ export default function FormDesigner() {
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
+ console.log('==========================================');
|
|
|
} catch (e) {
|
|
|
console.error('解析表单配置失败:', e);
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+ console.log('========== 表单回显 - 回显完成 ==========');
|
|
|
+ console.log('最终表单名称 (formName):', formName);
|
|
|
+ console.log('最终表单备注 (formRemark):', formRemark);
|
|
|
+ console.log('最终表单状态 (formStatus):', formStatus);
|
|
|
+ console.log('==========================================');
|
|
|
} catch (error: any) {
|
|
|
toast.error(error.message || '加载表单数据失败');
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ // 递归查找字段
|
|
|
+ const findField = (fields: FormField[], fieldId: string): FormField | null => {
|
|
|
+ for (const field of fields) {
|
|
|
+ if (field.id === fieldId) {
|
|
|
+ return field;
|
|
|
+ }
|
|
|
+ if (field.children && field.children.length > 0) {
|
|
|
+ const found = findField(field.children, fieldId);
|
|
|
+ if (found) return found;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ };
|
|
|
+
|
|
|
+ // 递归更新字段
|
|
|
+ const updateFieldRecursive = (fields: FormField[], fieldId: string, updates: Partial<FormField>): FormField[] => {
|
|
|
+ return fields.map(field => {
|
|
|
+ if (field.id === fieldId) {
|
|
|
+ return { ...field, ...updates };
|
|
|
+ }
|
|
|
+ if (field.children && field.children.length > 0) {
|
|
|
+ return { ...field, children: updateFieldRecursive(field.children, fieldId, updates) };
|
|
|
+ }
|
|
|
+ return field;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 递归删除字段
|
|
|
+ const deleteFieldRecursive = (fields: FormField[], fieldId: string): FormField[] => {
|
|
|
+ return fields.filter(field => {
|
|
|
+ if (field.id === fieldId) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (field.children && field.children.length > 0) {
|
|
|
+ field.children = deleteFieldRecursive(field.children, fieldId);
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ // 递归添加字段到容器
|
|
|
+ const addFieldToContainer = (fields: FormField[], parentId: string, newField: FormField): FormField[] => {
|
|
|
+ return fields.map(field => {
|
|
|
+ if (field.id === parentId) {
|
|
|
+ return { ...field, children: [...(field.children || []), newField] };
|
|
|
+ }
|
|
|
+ if (field.children && field.children.length > 0) {
|
|
|
+ return { ...field, children: addFieldToContainer(field.children, parentId, newField) };
|
|
|
+ }
|
|
|
+ return field;
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
// 添加字段
|
|
|
- const addField = (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file') => {
|
|
|
+ const addField = (type: FieldType, uploadType?: 'single-image' | 'multiple-image' | 'file', layoutColumns?: number, parentId?: string) => {
|
|
|
const fieldConfig: Record<FieldType, Partial<FormField>> = {
|
|
|
input: { placeholder: '请输入' },
|
|
|
textarea: { placeholder: '请输入内容' },
|
|
|
@@ -872,8 +1162,15 @@ export default function FormDesigner() {
|
|
|
},
|
|
|
treeselect: {},
|
|
|
timepicker: { placeholder: '请选择时间' },
|
|
|
- card: {},
|
|
|
- grid: {},
|
|
|
+ card: {
|
|
|
+ children: [],
|
|
|
+ cardTitle: '卡片容器'
|
|
|
+ },
|
|
|
+ grid: {
|
|
|
+ children: [],
|
|
|
+ gridColumns: (layoutColumns || 2) as 1 | 2 | 3,
|
|
|
+ label: layoutColumns === 3 ? '三栏布局' : '双栏布局'
|
|
|
+ },
|
|
|
tabs: {},
|
|
|
collapse: {},
|
|
|
alert: {
|
|
|
@@ -890,31 +1187,36 @@ export default function FormDesigner() {
|
|
|
const newField: FormField = {
|
|
|
id: `field_${Date.now()}`,
|
|
|
type,
|
|
|
- label: `字段${fields.length + 1}`,
|
|
|
- name: `field${fields.length + 1}`,
|
|
|
+ label: type === 'grid' ? (layoutColumns === 3 ? '三栏布局' : '双栏布局') : type === 'card' ? '卡片容器' : `字段${fields.length + 1}`,
|
|
|
+ name: `field${Date.now()}`,
|
|
|
required: false,
|
|
|
...fieldConfig[type],
|
|
|
};
|
|
|
|
|
|
- setFields([...fields, newField]);
|
|
|
+ // 如果指定了父容器ID,添加到容器中
|
|
|
+ if (parentId) {
|
|
|
+ setFields(addFieldToContainer(fields, parentId, newField));
|
|
|
+ } else {
|
|
|
+ setFields([...fields, newField]);
|
|
|
+ }
|
|
|
setSelectedFieldId(newField.id);
|
|
|
// 添加字段后,自动切换到组件配置标签页
|
|
|
setConfigTab('component');
|
|
|
markDirty();
|
|
|
};
|
|
|
|
|
|
- // 删除字段
|
|
|
+ // 删除字段(支持递归删除)
|
|
|
const deleteField = (id: string) => {
|
|
|
- setFields(fields.filter(f => f.id !== id));
|
|
|
+ setFields(deleteFieldRecursive(fields, id));
|
|
|
if (selectedFieldId === id) {
|
|
|
setSelectedFieldId(null);
|
|
|
}
|
|
|
markDirty();
|
|
|
};
|
|
|
|
|
|
- // 更新字段
|
|
|
+ // 更新字段(支持递归更新)
|
|
|
const updateField = (id: string, updates: Partial<FormField>) => {
|
|
|
- setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
|
|
+ setFields(updateFieldRecursive(fields, id, updates));
|
|
|
markDirty();
|
|
|
};
|
|
|
|
|
|
@@ -1013,6 +1315,13 @@ export default function FormDesigner() {
|
|
|
|
|
|
// 使用 useMemo 确保 backend 实例在组件生命周期内保持一致
|
|
|
const backend = useMemo(() => getHTML5Backend(), []);
|
|
|
+ const layoutColumns = formConfig.layoutColumns || 1;
|
|
|
+ const cardEnabled = formConfig.useCardLayout !== false;
|
|
|
+ const gridStyle = layoutColumns > 1 ? {
|
|
|
+ display: 'grid',
|
|
|
+ gridTemplateColumns: `repeat(${layoutColumns}, minmax(0, 1fr))`,
|
|
|
+ gap: '16px',
|
|
|
+ } : undefined;
|
|
|
|
|
|
return (
|
|
|
<DndProvider backend={backend}>
|
|
|
@@ -1050,7 +1359,7 @@ export default function FormDesigner() {
|
|
|
</Button>
|
|
|
</div>
|
|
|
|
|
|
- <div className="flex-1 flex overflow-hidden">
|
|
|
+ <div className="flex-1 flex overflow-hidden" style={{ height: 'calc(100vh - 64px)' }}>
|
|
|
{/* 左侧:组件库 */}
|
|
|
<div className="w-80 bg-white border-r border-gray-200 overflow-y-auto">
|
|
|
<div className="p-4 border-b border-gray-200">
|
|
|
@@ -1068,6 +1377,7 @@ export default function FormDesigner() {
|
|
|
label={comp.label}
|
|
|
icon={comp.icon}
|
|
|
uploadType={comp.uploadType}
|
|
|
+ layoutColumns={comp.layoutColumns}
|
|
|
onDrag={addField}
|
|
|
/>
|
|
|
))}
|
|
|
@@ -1078,45 +1388,113 @@ export default function FormDesigner() {
|
|
|
</div>
|
|
|
|
|
|
{/* 中间:设计画布 */}
|
|
|
- <div className="flex-1 overflow-y-auto h-full">
|
|
|
- <CanvasDropZone onDrop={addField}>
|
|
|
- <div className="max-w-4xl mx-auto h-full">
|
|
|
- <Card
|
|
|
- title={formConfig.name || "表单设计"}
|
|
|
- className="h-full flex flex-col"
|
|
|
- bodyStyle={{ flex: 1, overflow: 'auto' }}
|
|
|
- >
|
|
|
+ <div className="flex-1 overflow-hidden">
|
|
|
+ <CanvasDropZone onDrop={addField} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
|
|
+ <div className="w-full p-8" style={{ minHeight: '100%', overflow: 'auto' }}>
|
|
|
+ {formConfig.useCardLayout !== false ? (
|
|
|
+ <Card
|
|
|
+ title={formConfig.name || "表单设计"}
|
|
|
+ className="flex flex-col flex-1"
|
|
|
+ style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
|
|
|
+ bodyStyle={{ flex: 1, overflow: 'auto', height: 0 }}
|
|
|
+ >
|
|
|
{fields.length === 0 ? (
|
|
|
- <div className="text-center text-gray-400 py-20">
|
|
|
- <p className="text-sm mb-2">从左侧拖拽组件到此处开始设计</p>
|
|
|
+ <div className="pt-8 w-full text-center">
|
|
|
+ <p className="text-sm text-gray-400">从左侧拖拽组件到此处开始设计</p>
|
|
|
</div>
|
|
|
) : (
|
|
|
- <AntdForm
|
|
|
- layout={formConfig.labelPosition === 'top' ? 'vertical' : formConfig.labelPosition === 'left' ? 'horizontal' : 'horizontal'}
|
|
|
- size={formConfig.formSize === 'default' ? 'middle' : formConfig.formSize}
|
|
|
- requiredMark={formConfig.hideRequiredMark ? false : undefined}
|
|
|
- labelCol={formConfig.labelWidth ? {
|
|
|
- style: {
|
|
|
- width: `${formConfig.labelWidth}px`,
|
|
|
- textAlign: formConfig.labelPosition === 'left' ? 'left' : formConfig.labelPosition === 'right' ? 'right' : 'left'
|
|
|
- }
|
|
|
- } : undefined}
|
|
|
- >
|
|
|
- <div className="space-y-4">
|
|
|
- {fields.map((field, index) => (
|
|
|
- <FieldRenderer
|
|
|
- key={field.id}
|
|
|
- field={field}
|
|
|
- isSelected={selectedFieldId === field.id}
|
|
|
- onSelect={() => setSelectedFieldId(field.id)}
|
|
|
- onDelete={() => deleteField(field.id)}
|
|
|
- formConfig={formConfig}
|
|
|
- />
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- </AntdForm>
|
|
|
- )}
|
|
|
- </Card>
|
|
|
+ <AntdForm
|
|
|
+ layout={formConfig.labelPosition === 'top' ? 'vertical' : formConfig.labelPosition === 'left' ? 'horizontal' : 'horizontal'}
|
|
|
+ size={formConfig.formSize === 'default' ? 'middle' : formConfig.formSize}
|
|
|
+ requiredMark={formConfig.hideRequiredMark ? false : undefined}
|
|
|
+ labelCol={formConfig.labelWidth ? {
|
|
|
+ style: {
|
|
|
+ width: `${formConfig.labelWidth}px`,
|
|
|
+ textAlign: formConfig.labelPosition === 'left' ? 'left' : formConfig.labelPosition === 'right' ? 'right' : 'left'
|
|
|
+ }
|
|
|
+ } : undefined}
|
|
|
+ >
|
|
|
+ <div style={gridStyle} className={layoutColumns === 1 ? 'space-y-4' : ''}>
|
|
|
+ {fields.map((field) => (
|
|
|
+ <div
|
|
|
+ key={field.id}
|
|
|
+ style={
|
|
|
+ layoutColumns > 1
|
|
|
+ ? { gridColumn: `span ${Math.min(layoutColumns, field.span || 1)}` }
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <FieldRenderer
|
|
|
+ field={field}
|
|
|
+ isSelected={selectedFieldId === field.id}
|
|
|
+ onSelect={() => setSelectedFieldId(field.id)}
|
|
|
+ onDelete={() => deleteField(field.id)}
|
|
|
+ onAddFieldToContainer={(parentId, type, uploadType, layoutCols) => {
|
|
|
+ addField(type, uploadType, layoutCols, parentId);
|
|
|
+ }}
|
|
|
+ onUpdateField={updateField}
|
|
|
+ onDeleteField={deleteField}
|
|
|
+ onSelectField={setSelectedFieldId}
|
|
|
+ formConfig={formConfig}
|
|
|
+ selectedFieldId={selectedFieldId}
|
|
|
+ allFields={fields}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </AntdForm>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+ ) : (
|
|
|
+ <div className="flex-1 flex flex-col" style={{ height: '100%', overflow: 'auto' }}>
|
|
|
+ {fields.length === 0 ? (
|
|
|
+ <div className="pt-8 w-full text-center">
|
|
|
+ <p className="text-sm text-gray-400">从左侧拖拽组件到此处开始设计</p>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <AntdForm
|
|
|
+ layout={formConfig.labelPosition === 'top' ? 'vertical' : formConfig.labelPosition === 'left' ? 'horizontal' : 'horizontal'}
|
|
|
+ size={formConfig.formSize === 'default' ? 'middle' : formConfig.formSize}
|
|
|
+ requiredMark={formConfig.hideRequiredMark ? false : undefined}
|
|
|
+ labelCol={formConfig.labelWidth ? {
|
|
|
+ style: {
|
|
|
+ width: `${formConfig.labelWidth}px`,
|
|
|
+ textAlign: formConfig.labelPosition === 'left' ? 'left' : formConfig.labelPosition === 'right' ? 'right' : 'left'
|
|
|
+ }
|
|
|
+ } : undefined}
|
|
|
+ >
|
|
|
+ <div style={gridStyle} className={layoutColumns === 1 ? 'space-y-4' : ''}>
|
|
|
+ {fields.map((field) => (
|
|
|
+ <div
|
|
|
+ key={field.id}
|
|
|
+ style={
|
|
|
+ layoutColumns > 1
|
|
|
+ ? { gridColumn: `span ${Math.min(layoutColumns, field.span || 1)}` }
|
|
|
+ : undefined
|
|
|
+ }
|
|
|
+ >
|
|
|
+ <FieldRenderer
|
|
|
+ field={field}
|
|
|
+ isSelected={selectedFieldId === field.id}
|
|
|
+ onSelect={() => setSelectedFieldId(field.id)}
|
|
|
+ onDelete={() => deleteField(field.id)}
|
|
|
+ formConfig={formConfig}
|
|
|
+ onAddFieldToContainer={(parentId, type, uploadType, layoutCols) => {
|
|
|
+ addField(type, uploadType, layoutCols, parentId);
|
|
|
+ }}
|
|
|
+ onUpdateField={updateField}
|
|
|
+ onDeleteField={deleteField}
|
|
|
+ onSelectField={setSelectedFieldId}
|
|
|
+ selectedFieldId={selectedFieldId}
|
|
|
+ allFields={fields}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </AntdForm>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</CanvasDropZone>
|
|
|
</div>
|
|
|
@@ -1420,6 +1798,19 @@ export default function FormDesigner() {
|
|
|
<Select.Option value="large">大</Select.Option>
|
|
|
</Select>
|
|
|
</div>
|
|
|
+ {layoutColumns > 1 && (
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">跨列数</label>
|
|
|
+ <InputNumber
|
|
|
+ value={field.span || 1}
|
|
|
+ min={1}
|
|
|
+ max={layoutColumns}
|
|
|
+ onChange={(value) => updateField(field.id, { span: value || 1 })}
|
|
|
+ size="small"
|
|
|
+ className="w-full"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
@@ -1940,6 +2331,26 @@ export default function FormDesigner() {
|
|
|
addonAfter="px"
|
|
|
/>
|
|
|
</div>
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">表单布局</label>
|
|
|
+ <Radio.Group
|
|
|
+ value={layoutColumns}
|
|
|
+ onChange={(e) => updateFormConfig({ layoutColumns: e.target.value as 1 | 2 | 3 })}
|
|
|
+ size="small"
|
|
|
+ >
|
|
|
+ <Radio value={1}>单栏</Radio>
|
|
|
+ <Radio value={2}>双栏</Radio>
|
|
|
+ <Radio value={3}>三栏</Radio>
|
|
|
+ </Radio.Group>
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <label className="text-xs text-gray-600">使用卡片容器</label>
|
|
|
+ <Switch
|
|
|
+ checked={formConfig.useCardLayout !== false}
|
|
|
+ onChange={(checked) => updateFormConfig({ useCardLayout: checked })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
<Divider className="my-2" />
|
|
|
<div className="space-y-3">
|
|
|
<div className="flex items-center justify-between">
|
|
|
@@ -2038,7 +2449,6 @@ export default function FormDesigner() {
|
|
|
form={previewForm}
|
|
|
layout={formConfig.labelPosition === 'top' ? 'vertical' : formConfig.labelPosition === 'left' ? 'horizontal' : 'horizontal'}
|
|
|
size={formConfig.formSize === 'default' ? 'middle' : formConfig.formSize}
|
|
|
- className="space-y-4"
|
|
|
requiredMark={formConfig.hideRequiredMark ? false : undefined}
|
|
|
labelCol={formConfig.labelWidth ? {
|
|
|
style: {
|
|
|
@@ -2048,14 +2458,16 @@ export default function FormDesigner() {
|
|
|
} : undefined}
|
|
|
validateMessages={formConfig.inlineValidation ? undefined : { required: '' }}
|
|
|
>
|
|
|
+ <div style={gridStyle} className={layoutColumns === 1 ? 'space-y-4' : ''}>
|
|
|
{fields.map((field) => {
|
|
|
+ const spanStyle = layoutColumns > 1 ? { gridColumn: `span ${Math.min(layoutColumns, field.span || 1)}` } : undefined;
|
|
|
if (field.type === 'alert') {
|
|
|
const themeClass =
|
|
|
field.alertTheme === 'dark'
|
|
|
? 'bg-gray-800 text-white border-gray-700'
|
|
|
: '';
|
|
|
return (
|
|
|
- <div key={field.id} className="mb-4">
|
|
|
+ <div key={field.id} className="mb-4" style={spanStyle}>
|
|
|
<Alert
|
|
|
message={field.alertTitle || field.label}
|
|
|
description={field.alertDescription}
|
|
|
@@ -2073,8 +2485,8 @@ export default function FormDesigner() {
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
+ <div key={field.id} style={spanStyle}>
|
|
|
<AntdForm.Item
|
|
|
- key={field.id}
|
|
|
label={field.label + (formConfig.labelSuffix || '')}
|
|
|
name={field.name}
|
|
|
required={field.required && !formConfig.hideRequiredMark}
|
|
|
@@ -2303,8 +2715,10 @@ export default function FormDesigner() {
|
|
|
</Upload>
|
|
|
)}
|
|
|
</AntdForm.Item>
|
|
|
+ </div>
|
|
|
);
|
|
|
})}
|
|
|
+ </div>
|
|
|
</AntdForm>
|
|
|
</Modal>
|
|
|
|