表单设计器
This commit is contained in:
parent
e0e1f4e989
commit
46232db651
@ -12,6 +12,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^5.2.6",
|
"@ant-design/icons": "^5.2.6",
|
||||||
"@ant-design/pro-components": "^2.8.2",
|
"@ant-design/pro-components": "^2.8.2",
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@hookform/resolvers": "^3.9.1",
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
|||||||
@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 左侧组件面板
|
||||||
|
* 显示可拖拽的表单组件列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useDraggable } from '@dnd-kit/core';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Collapse, Typography } from 'antd';
|
||||||
|
import type { ComponentMeta } from '../config';
|
||||||
|
import { getComponentsByCategory } from '../config';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
const { Panel } = Collapse;
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// 可拖拽组件项
|
||||||
|
const DraggableComponent: React.FC<{ component: ComponentMeta }> = ({ component }) => {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: `new-${component.type}`,
|
||||||
|
data: {
|
||||||
|
type: component.type,
|
||||||
|
isNew: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
cursor: 'move',
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = component.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
className="form-designer-component-item"
|
||||||
|
>
|
||||||
|
<Icon style={{ marginRight: 8, fontSize: 16 }} />
|
||||||
|
<Text>{component.label}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件面板
|
||||||
|
const ComponentPanel: React.FC = () => {
|
||||||
|
const componentsByCategory = getComponentsByCategory();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-designer-component-panel">
|
||||||
|
<div className="form-designer-component-panel-header">
|
||||||
|
<Text strong>组件列表</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Collapse
|
||||||
|
defaultActiveKey={['基础字段', '高级字段', '布局字段']}
|
||||||
|
ghost
|
||||||
|
bordered={false}
|
||||||
|
>
|
||||||
|
{Object.entries(componentsByCategory).map(([category, components]) => (
|
||||||
|
<Panel header={category} key={category}>
|
||||||
|
<div className="form-designer-component-list">
|
||||||
|
{components.map((component) => (
|
||||||
|
<DraggableComponent key={component.type} component={component} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</Collapse>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ComponentPanel;
|
||||||
|
|
||||||
80
frontend/src/pages/FormDesigner/components/DesignCanvas.tsx
Normal file
80
frontend/src/pages/FormDesigner/components/DesignCanvas.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* 中间设计画布
|
||||||
|
* 可拖放字段的区域
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
|
import { Empty, Typography } from 'antd';
|
||||||
|
import FieldItem from './FieldItem';
|
||||||
|
import type { FieldConfig } from '../types';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface DesignCanvasProps {
|
||||||
|
fields: FieldConfig[];
|
||||||
|
selectedFieldId?: string;
|
||||||
|
onSelectField: (fieldId: string) => void;
|
||||||
|
onDeleteField: (fieldId: string) => void;
|
||||||
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||||
|
fields,
|
||||||
|
selectedFieldId,
|
||||||
|
onSelectField,
|
||||||
|
onDeleteField,
|
||||||
|
labelAlign = 'right',
|
||||||
|
}) => {
|
||||||
|
const { setNodeRef } = useDroppable({
|
||||||
|
id: 'canvas',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="form-designer-canvas">
|
||||||
|
<div className="form-designer-canvas-header">
|
||||||
|
<Text strong style={{ fontSize: 16 }}>表单设计</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className="form-designer-canvas-body"
|
||||||
|
>
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div className="form-designer-canvas-empty">
|
||||||
|
<Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description="从左侧拖拽组件到此处开始设计表单"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SortableContext
|
||||||
|
items={fields.map(f => f.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div className="form-designer-field-list">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<FieldItem
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
isSelected={field.id === selectedFieldId}
|
||||||
|
onSelect={() => onSelectField(field.id)}
|
||||||
|
onDelete={() => onDeleteField(field.id)}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={onSelectField}
|
||||||
|
onDeleteField={onDeleteField}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DesignCanvas;
|
||||||
|
|
||||||
102
frontend/src/pages/FormDesigner/components/FieldItem.tsx
Normal file
102
frontend/src/pages/FormDesigner/components/FieldItem.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* 画布中的字段项
|
||||||
|
* 支持拖拽排序、选中、删除
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Button, Space } from 'antd';
|
||||||
|
import { HolderOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons';
|
||||||
|
import type { FieldConfig } from '../types';
|
||||||
|
import FieldRenderer from './FieldRenderer';
|
||||||
|
import '../styles.css';
|
||||||
|
|
||||||
|
interface FieldItemProps {
|
||||||
|
field: FieldConfig;
|
||||||
|
isSelected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
selectedFieldId?: string; // 传递给栅格内部字段
|
||||||
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onDeleteField?: (fieldId: string) => void;
|
||||||
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldItem: React.FC<FieldItemProps> = ({
|
||||||
|
field,
|
||||||
|
isSelected,
|
||||||
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
selectedFieldId,
|
||||||
|
onSelectField,
|
||||||
|
onDeleteField,
|
||||||
|
labelAlign = 'right',
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: field.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`form-designer-field-item ${isSelected ? 'selected' : ''}`}
|
||||||
|
onClick={onSelect}
|
||||||
|
>
|
||||||
|
<div className="form-designer-field-drag-handle" {...attributes} {...listeners}>
|
||||||
|
<HolderOutlined />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-designer-field-content">
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={onSelectField}
|
||||||
|
onDeleteField={onDeleteField}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-designer-field-actions">
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<CopyOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// TODO: 复制功能
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldItem;
|
||||||
|
|
||||||
264
frontend/src/pages/FormDesigner/components/FieldRenderer.tsx
Normal file
264
frontend/src/pages/FormDesigner/components/FieldRenderer.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/**
|
||||||
|
* 字段渲染器
|
||||||
|
* 根据字段类型渲染对应的表单组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Select,
|
||||||
|
Radio,
|
||||||
|
Checkbox,
|
||||||
|
DatePicker,
|
||||||
|
TimePicker,
|
||||||
|
Switch,
|
||||||
|
Slider,
|
||||||
|
Rate,
|
||||||
|
Upload,
|
||||||
|
Cascader,
|
||||||
|
Divider,
|
||||||
|
Button,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { UploadOutlined } from '@ant-design/icons';
|
||||||
|
import type { FieldConfig } from '../types';
|
||||||
|
import GridField from './GridField';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const { TextArea } = Input;
|
||||||
|
|
||||||
|
interface FieldRendererProps {
|
||||||
|
field: FieldConfig;
|
||||||
|
value?: any;
|
||||||
|
onChange?: (value: any) => void;
|
||||||
|
isPreview?: boolean; // 是否为预览模式
|
||||||
|
selectedFieldId?: string; // 当前选中的字段ID
|
||||||
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onDeleteField?: (fieldId: string) => void;
|
||||||
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||||
|
field,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
isPreview = false,
|
||||||
|
selectedFieldId,
|
||||||
|
onSelectField,
|
||||||
|
onDeleteField,
|
||||||
|
labelAlign = 'right',
|
||||||
|
}) => {
|
||||||
|
// 布局组件特殊处理
|
||||||
|
if (field.type === 'divider') {
|
||||||
|
return <Divider>{field.label}</Divider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'text') {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0' }}>
|
||||||
|
<Text>{field.content || '这是一段文字'}</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'grid') {
|
||||||
|
return (
|
||||||
|
<GridField
|
||||||
|
field={field}
|
||||||
|
isPreview={isPreview}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={onSelectField}
|
||||||
|
onDeleteField={onDeleteField}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderField = () => {
|
||||||
|
switch (field.type) {
|
||||||
|
case 'input':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'textarea':
|
||||||
|
return (
|
||||||
|
<TextArea
|
||||||
|
rows={field.rows || 4}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'select':
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
options={field.options}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'radio':
|
||||||
|
return (
|
||||||
|
<Radio.Group
|
||||||
|
options={field.options}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'checkbox':
|
||||||
|
return (
|
||||||
|
<Checkbox.Group
|
||||||
|
options={field.options}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'datetime':
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
showTime
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'time':
|
||||||
|
return (
|
||||||
|
<TimePicker
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'switch':
|
||||||
|
return (
|
||||||
|
<Switch
|
||||||
|
disabled={field.disabled}
|
||||||
|
checked={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'slider':
|
||||||
|
return (
|
||||||
|
<Slider
|
||||||
|
min={field.min}
|
||||||
|
max={field.max}
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'rate':
|
||||||
|
return (
|
||||||
|
<Rate
|
||||||
|
disabled={field.disabled}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'upload':
|
||||||
|
return (
|
||||||
|
<Upload
|
||||||
|
maxCount={field.maxCount}
|
||||||
|
disabled={field.disabled}
|
||||||
|
>
|
||||||
|
<Button icon={<UploadOutlined />}>上传文件</Button>
|
||||||
|
</Upload>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'cascader':
|
||||||
|
return (
|
||||||
|
<Cascader
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.disabled}
|
||||||
|
options={field.options as any}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <Input placeholder={field.placeholder} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 预览模式下,外部已经用 Form.Item 包裹了,这里直接返回控件
|
||||||
|
if (isPreview) {
|
||||||
|
return renderField();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设计模式下,用 Form 和 Form.Item 包裹以显示标签,并支持动态布局
|
||||||
|
const isVertical = labelAlign === 'top';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
layout={isVertical ? 'vertical' : 'horizontal'}
|
||||||
|
labelAlign={isVertical ? undefined : labelAlign}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label={field.label}
|
||||||
|
required={field.required}
|
||||||
|
style={{ marginBottom: 0 }}
|
||||||
|
labelCol={isVertical ? undefined : { span: 6 }}
|
||||||
|
wrapperCol={isVertical ? undefined : { span: 18 }}
|
||||||
|
>
|
||||||
|
{renderField()}
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FieldRenderer;
|
||||||
|
|
||||||
135
frontend/src/pages/FormDesigner/components/FormPreview.tsx
Normal file
135
frontend/src/pages/FormDesigner/components/FormPreview.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 表单预览组件
|
||||||
|
* 预览设计好的表单效果
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Form, Button, message } from 'antd';
|
||||||
|
import type { FieldConfig, FormConfig } from '../types';
|
||||||
|
import FieldRenderer from './FieldRenderer';
|
||||||
|
import GridFieldPreview from './GridFieldPreview';
|
||||||
|
|
||||||
|
interface FormPreviewProps {
|
||||||
|
fields: FieldConfig[];
|
||||||
|
formConfig: FormConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormPreview: React.FC<FormPreviewProps> = ({ fields, formConfig }) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
console.log('表单提交数据:', values);
|
||||||
|
setFormData(values);
|
||||||
|
message.success('表单提交成功!请查看控制台');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('请填写必填项');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
form.resetFields();
|
||||||
|
setFormData({});
|
||||||
|
message.info('表单已重置');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 递归渲染字段(包括栅格布局内的字段)
|
||||||
|
const renderFields = (fieldList: FieldConfig[]): React.ReactNode => {
|
||||||
|
return fieldList.map((field) => {
|
||||||
|
// 布局组件:文本、分割线
|
||||||
|
if (field.type === 'text' || field.type === 'divider') {
|
||||||
|
return (
|
||||||
|
<div key={field.id}>
|
||||||
|
<FieldRenderer field={field} isPreview={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 栅格布局:使用专门的预览组件,自动处理内部字段的 Form.Item
|
||||||
|
if (field.type === 'grid') {
|
||||||
|
return (
|
||||||
|
<GridFieldPreview
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
formData={formData}
|
||||||
|
onFieldChange={(name, value) => setFormData(prev => ({ ...prev, [name]: value }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通表单组件使用 Form.Item 包裹
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={field.id}
|
||||||
|
label={field.label}
|
||||||
|
name={field.name}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: field.required,
|
||||||
|
message: `请输入${field.label}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FieldRenderer
|
||||||
|
field={field}
|
||||||
|
value={formData[field.name]}
|
||||||
|
onChange={(value) => setFormData(prev => ({ ...prev, [field.name]: value }))}
|
||||||
|
isPreview={true}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 40, textAlign: 'center', color: '#8c8c8c' }}>
|
||||||
|
表单为空
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formLayout = formConfig.labelAlign === 'top' ? 'vertical' : 'horizontal';
|
||||||
|
const labelColSpan = formConfig.labelAlign === 'left' ? 6 : formConfig.labelAlign === 'right' ? 6 : undefined;
|
||||||
|
const wrapperColSpan = formConfig.labelAlign === 'left' || formConfig.labelAlign === 'right' ? 18 : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout={formLayout}
|
||||||
|
size={formConfig.size}
|
||||||
|
labelAlign={formConfig.labelAlign === 'top' ? undefined : formConfig.labelAlign}
|
||||||
|
labelCol={labelColSpan ? { span: labelColSpan } : undefined}
|
||||||
|
wrapperCol={wrapperColSpan ? { span: wrapperColSpan } : undefined}
|
||||||
|
>
|
||||||
|
{formConfig.title && (
|
||||||
|
<h3 style={{ marginBottom: 24 }}>{formConfig.title}</h3>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderFields(fields)}
|
||||||
|
|
||||||
|
<Form.Item wrapperCol={wrapperColSpan ? { offset: labelColSpan, span: wrapperColSpan } : undefined}>
|
||||||
|
<Button type="primary" onClick={handleSubmit} style={{ marginRight: 8 }}>
|
||||||
|
提交
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleReset}>
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
{Object.keys(formData).length > 0 && (
|
||||||
|
<div style={{ marginTop: 24, padding: 16, background: '#f5f5f5', borderRadius: 4 }}>
|
||||||
|
<h4>表单数据:</h4>
|
||||||
|
<pre>{JSON.stringify(formData, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormPreview;
|
||||||
|
|
||||||
196
frontend/src/pages/FormDesigner/components/GridField.tsx
Normal file
196
frontend/src/pages/FormDesigner/components/GridField.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* 栅格布局字段组件
|
||||||
|
* 支持拖入子组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
|
import { Row, Col, Button, Space } from 'antd';
|
||||||
|
import { DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import type { FieldConfig } from '../types';
|
||||||
|
import FieldRenderer from './FieldRenderer';
|
||||||
|
|
||||||
|
interface GridFieldProps {
|
||||||
|
field: FieldConfig;
|
||||||
|
isPreview?: boolean; // 是否为预览模式
|
||||||
|
selectedFieldId?: string; // 当前选中的字段ID
|
||||||
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onDeleteField?: (fieldId: string) => void;
|
||||||
|
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridField: React.FC<GridFieldProps> = ({
|
||||||
|
field,
|
||||||
|
isPreview = false,
|
||||||
|
selectedFieldId,
|
||||||
|
onSelectField,
|
||||||
|
onDeleteField,
|
||||||
|
labelAlign = 'right',
|
||||||
|
}) => {
|
||||||
|
const columns = field.columns || 2;
|
||||||
|
const colSpan = 24 / columns;
|
||||||
|
const children = field.children || Array(columns).fill([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 0', width: '100%' }}>
|
||||||
|
<Row gutter={field.gutter || 16}>
|
||||||
|
{children.map((columnFields, colIndex) => {
|
||||||
|
const dropId = `grid-${field.id}-col-${colIndex}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GridColumn
|
||||||
|
key={colIndex}
|
||||||
|
dropId={dropId}
|
||||||
|
span={colSpan}
|
||||||
|
fields={columnFields}
|
||||||
|
isPreview={isPreview}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={onSelectField}
|
||||||
|
onDeleteField={onDeleteField}
|
||||||
|
labelAlign={labelAlign}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 栅格列组件
|
||||||
|
interface GridColumnProps {
|
||||||
|
dropId: string;
|
||||||
|
span: number;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
isPreview?: boolean;
|
||||||
|
selectedFieldId?: string;
|
||||||
|
onSelectField?: (fieldId: string) => void;
|
||||||
|
onDeleteField?: (fieldId: string) => void;
|
||||||
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridColumn: React.FC<GridColumnProps> = ({
|
||||||
|
dropId,
|
||||||
|
span,
|
||||||
|
fields,
|
||||||
|
isPreview = false,
|
||||||
|
selectedFieldId,
|
||||||
|
onSelectField,
|
||||||
|
onDeleteField,
|
||||||
|
labelAlign = 'right',
|
||||||
|
}) => {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
|
id: dropId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预览模式下的样式
|
||||||
|
if (isPreview) {
|
||||||
|
return (
|
||||||
|
<Col span={span}>
|
||||||
|
<div>
|
||||||
|
{fields.map((childField) => (
|
||||||
|
<div key={childField.id} style={{ marginBottom: 8 }}>
|
||||||
|
<FieldRenderer field={childField} isPreview={isPreview} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设计模式下的样式
|
||||||
|
return (
|
||||||
|
<Col span={span}>
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
style={{
|
||||||
|
minHeight: 100,
|
||||||
|
padding: 12,
|
||||||
|
border: `2px dashed ${isOver ? '#1890ff' : '#d9d9d9'}`,
|
||||||
|
borderRadius: 6,
|
||||||
|
background: isOver ? '#f0f5ff' : '#fafafa',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 栅格占位标签 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
fontSize: 10,
|
||||||
|
color: '#bfbfbf',
|
||||||
|
background: '#fff',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: 3,
|
||||||
|
border: '1px solid #e8e8e8',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{span}/24
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fields.length === 0 ? (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#8c8c8c',
|
||||||
|
fontSize: 12,
|
||||||
|
padding: '20px 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
拖拽组件到此列
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{fields.map((childField) => (
|
||||||
|
<div
|
||||||
|
key={childField.id}
|
||||||
|
style={{
|
||||||
|
marginBottom: 8,
|
||||||
|
padding: 8,
|
||||||
|
border: selectedFieldId === childField.id ? '2px solid #1890ff' : '1px solid #e8e8e8',
|
||||||
|
borderRadius: 4,
|
||||||
|
background: '#fff',
|
||||||
|
position: 'relative',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // 阻止事件冒泡
|
||||||
|
onSelectField?.(childField.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FieldRenderer field={childField} isPreview={isPreview} labelAlign={labelAlign} />
|
||||||
|
|
||||||
|
{/* 字段操作按钮 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
zIndex: 10, // 确保按钮在最上层
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Space size="small">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteField?.(childField.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GridField;
|
||||||
|
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 栅格布局字段预览组件
|
||||||
|
* 用于在表单预览模式下渲染栅格及其子字段
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { Row, Col, Form } from 'antd';
|
||||||
|
import type { FieldConfig } from '../types';
|
||||||
|
import FieldRenderer from './FieldRenderer';
|
||||||
|
|
||||||
|
interface GridFieldPreviewProps {
|
||||||
|
field: FieldConfig;
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
onFieldChange?: (name: string, value: any) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GridFieldPreview: React.FC<GridFieldPreviewProps> = ({
|
||||||
|
field,
|
||||||
|
formData = {},
|
||||||
|
onFieldChange
|
||||||
|
}) => {
|
||||||
|
const columns = field.columns || 2;
|
||||||
|
const colSpan = 24 / columns;
|
||||||
|
const children = field.children || Array(columns).fill([]);
|
||||||
|
|
||||||
|
const renderFieldItem = (childField: FieldConfig) => {
|
||||||
|
// 布局组件直接渲染,不需要 Form.Item
|
||||||
|
if (['text', 'divider', 'grid'].includes(childField.type)) {
|
||||||
|
return (
|
||||||
|
<div key={childField.id} style={{ marginBottom: 8 }}>
|
||||||
|
<FieldRenderer field={childField} isPreview={true} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通表单字段用 Form.Item 包裹
|
||||||
|
return (
|
||||||
|
<Form.Item
|
||||||
|
key={childField.id}
|
||||||
|
label={childField.label}
|
||||||
|
name={childField.name}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: childField.required,
|
||||||
|
message: `请输入${childField.label}`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<FieldRenderer
|
||||||
|
field={childField}
|
||||||
|
value={formData[childField.name]}
|
||||||
|
onChange={(value) => onFieldChange?.(childField.name, value)}
|
||||||
|
isPreview={true}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Row gutter={field.gutter || 16}>
|
||||||
|
{children.map((columnFields, colIndex) => (
|
||||||
|
<Col key={colIndex} span={colSpan}>
|
||||||
|
{columnFields.map((childField: FieldConfig) => renderFieldItem(childField))}
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GridFieldPreview;
|
||||||
|
|
||||||
286
frontend/src/pages/FormDesigner/components/PropertyPanel.tsx
Normal file
286
frontend/src/pages/FormDesigner/components/PropertyPanel.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* 右侧属性面板
|
||||||
|
* 配置选中字段的属性
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Switch,
|
||||||
|
Radio,
|
||||||
|
Button,
|
||||||
|
Space,
|
||||||
|
Tabs,
|
||||||
|
Typography,
|
||||||
|
Divider,
|
||||||
|
} from 'antd';
|
||||||
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import type { FieldConfig, FormConfig } from '../types';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
const { TabPane } = Tabs;
|
||||||
|
|
||||||
|
interface PropertyPanelProps {
|
||||||
|
selectedField?: FieldConfig;
|
||||||
|
formConfig: FormConfig;
|
||||||
|
onFieldChange: (field: FieldConfig) => void;
|
||||||
|
onFormConfigChange: (config: FormConfig) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||||
|
selectedField,
|
||||||
|
formConfig,
|
||||||
|
onFieldChange,
|
||||||
|
onFormConfigChange,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedField) {
|
||||||
|
form.setFieldsValue(selectedField);
|
||||||
|
}
|
||||||
|
}, [selectedField, form]);
|
||||||
|
|
||||||
|
const handleFieldUpdate = (changedValues: any) => {
|
||||||
|
if (selectedField) {
|
||||||
|
let updatedField = { ...selectedField, ...changedValues };
|
||||||
|
|
||||||
|
// 特殊处理:栅格列数变化时,调整 children 数组
|
||||||
|
if (selectedField.type === 'grid' && changedValues.columns !== undefined) {
|
||||||
|
const newColumns = changedValues.columns;
|
||||||
|
const oldChildren = selectedField.children || [];
|
||||||
|
const newChildren: FieldConfig[][] = [];
|
||||||
|
|
||||||
|
// 保留旧数据,调整数组长度
|
||||||
|
for (let i = 0; i < newColumns; i++) {
|
||||||
|
newChildren[i] = oldChildren[i] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedField = { ...updatedField, children: newChildren };
|
||||||
|
}
|
||||||
|
|
||||||
|
onFieldChange(updatedField);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加选项
|
||||||
|
const addOption = () => {
|
||||||
|
if (selectedField && selectedField.options) {
|
||||||
|
const newOptions = [
|
||||||
|
...selectedField.options,
|
||||||
|
{ label: `选项${selectedField.options.length + 1}`, value: `${selectedField.options.length + 1}` },
|
||||||
|
];
|
||||||
|
onFieldChange({ ...selectedField, options: newOptions });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除选项
|
||||||
|
const deleteOption = (index: number) => {
|
||||||
|
if (selectedField && selectedField.options) {
|
||||||
|
const newOptions = selectedField.options.filter((_, i) => i !== index);
|
||||||
|
onFieldChange({ ...selectedField, options: newOptions });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 更新选项
|
||||||
|
const updateOption = (index: number, field: 'label' | 'value', value: string) => {
|
||||||
|
if (selectedField && selectedField.options) {
|
||||||
|
const newOptions = [...selectedField.options];
|
||||||
|
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||||
|
onFieldChange({ ...selectedField, options: newOptions });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFieldProperties = () => {
|
||||||
|
if (!selectedField) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 24, textAlign: 'center', color: '#8c8c8c' }}>
|
||||||
|
请从画布中选择一个字段
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOptions = ['select', 'radio', 'checkbox', 'cascader'].includes(selectedField.type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onValuesChange={handleFieldUpdate}
|
||||||
|
style={{ padding: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item label="字段标签" name="label" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入字段标签" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="字段名称" name="name" rules={[{ required: true }]}>
|
||||||
|
<Input placeholder="请输入字段名称(英文)" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{selectedField.type !== 'divider' && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="占位提示" name="placeholder">
|
||||||
|
<Input placeholder="请输入占位提示" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="是否必填" name="required" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="是否禁用" name="disabled" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedField.type === 'textarea' && (
|
||||||
|
<Form.Item label="行数" name="rows">
|
||||||
|
<InputNumber min={1} max={20} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(selectedField.type === 'number' || selectedField.type === 'slider') && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="最小值" name="min">
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="最大值" name="max">
|
||||||
|
<InputNumber style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedField.type === 'upload' && (
|
||||||
|
<Form.Item label="最大文件数" name="maxCount">
|
||||||
|
<InputNumber min={1} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedField.type === 'text' && (
|
||||||
|
<Form.Item label="文本内容" name="content">
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入文本内容" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{selectedField.type === 'grid' && (
|
||||||
|
<>
|
||||||
|
<Form.Item label="列数" name="columns">
|
||||||
|
<InputNumber min={1} max={24} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label="间距" name="gutter">
|
||||||
|
<InputNumber min={0} max={48} style={{ width: '100%' }} addonAfter="px" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasOptions && selectedField.options && (
|
||||||
|
<div>
|
||||||
|
<Divider style={{ margin: '16px 0' }} />
|
||||||
|
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text strong>选项配置</Text>
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={addOption}
|
||||||
|
>
|
||||||
|
添加选项
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
{selectedField.options.map((option, index) => (
|
||||||
|
<div key={index} style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Input
|
||||||
|
placeholder="标签"
|
||||||
|
value={option.label}
|
||||||
|
onChange={(e) => updateOption(index, 'label', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="值"
|
||||||
|
value={option.value}
|
||||||
|
onChange={(e) => updateOption(index, 'value', e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => deleteOption(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFormProperties = () => {
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={formConfig}
|
||||||
|
onValuesChange={(_, allValues) => onFormConfigChange(allValues)}
|
||||||
|
style={{ padding: 16 }}
|
||||||
|
>
|
||||||
|
<Form.Item label="标签对齐方式" name="labelAlign">
|
||||||
|
<Radio.Group buttonStyle="solid">
|
||||||
|
<Radio.Button value="left">左对齐</Radio.Button>
|
||||||
|
<Radio.Button value="right">右对齐</Radio.Button>
|
||||||
|
<Radio.Button value="top">顶部对齐</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="表单宽度" name="formWidth">
|
||||||
|
<InputNumber
|
||||||
|
min={400}
|
||||||
|
max={1200}
|
||||||
|
step={50}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
addonAfter="px"
|
||||||
|
placeholder="默认 600"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item label="组件尺寸" name="size">
|
||||||
|
<Radio.Group buttonStyle="solid">
|
||||||
|
<Radio.Button value="middle">medium</Radio.Button>
|
||||||
|
<Radio.Button value="small">small</Radio.Button>
|
||||||
|
<Radio.Button value="mini">mini</Radio.Button>
|
||||||
|
</Radio.Group>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: 350,
|
||||||
|
height: '100%',
|
||||||
|
background: '#fff',
|
||||||
|
borderLeft: '1px solid #e8e8e8',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
padding: 16,
|
||||||
|
borderBottom: '1px solid #e8e8e8',
|
||||||
|
background: '#fafafa',
|
||||||
|
}}>
|
||||||
|
<Text strong>属性配置</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultActiveKey="field" style={{ padding: '0 0 0 16px' }}>
|
||||||
|
<TabPane tab="字段属性" key="field">
|
||||||
|
{renderFieldProperties()}
|
||||||
|
</TabPane>
|
||||||
|
<TabPane tab="表单属性" key="form">
|
||||||
|
{renderFormProperties()}
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyPanel;
|
||||||
|
|
||||||
226
frontend/src/pages/FormDesigner/config.ts
Normal file
226
frontend/src/pages/FormDesigner/config.ts
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器组件配置
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
FormOutlined,
|
||||||
|
FontSizeOutlined,
|
||||||
|
NumberOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
CheckSquareOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
FieldTimeOutlined,
|
||||||
|
SwitcherOutlined,
|
||||||
|
SlidersOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
ApartmentOutlined,
|
||||||
|
MinusOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
BorderOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { FieldType, FieldConfig } from './types';
|
||||||
|
|
||||||
|
// 组件元数据
|
||||||
|
export interface ComponentMeta {
|
||||||
|
type: FieldType;
|
||||||
|
label: string;
|
||||||
|
icon: any;
|
||||||
|
category: '基础字段' | '高级字段' | '布局字段';
|
||||||
|
defaultConfig: Partial<FieldConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件列表配置
|
||||||
|
export const COMPONENT_LIST: ComponentMeta[] = [
|
||||||
|
// 基础字段
|
||||||
|
{
|
||||||
|
type: 'input',
|
||||||
|
label: '单行文本',
|
||||||
|
icon: FormOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请输入',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'textarea',
|
||||||
|
label: '多行文本',
|
||||||
|
icon: FontSizeOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请输入',
|
||||||
|
rows: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'number',
|
||||||
|
label: '数字输入',
|
||||||
|
icon: NumberOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请输入数字',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
label: '下拉选择',
|
||||||
|
icon: DownOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请选择',
|
||||||
|
options: [
|
||||||
|
{ label: '选项1', value: '1' },
|
||||||
|
{ label: '选项2', value: '2' },
|
||||||
|
{ label: '选项3', value: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'radio',
|
||||||
|
label: '单选框组',
|
||||||
|
icon: CheckCircleOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
options: [
|
||||||
|
{ label: '选项1', value: '1' },
|
||||||
|
{ label: '选项2', value: '2' },
|
||||||
|
{ label: '选项3', value: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'checkbox',
|
||||||
|
label: '多选框组',
|
||||||
|
icon: CheckSquareOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
options: [
|
||||||
|
{ label: '选项1', value: '1' },
|
||||||
|
{ label: '选项2', value: '2' },
|
||||||
|
{ label: '选项3', value: '3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'date',
|
||||||
|
label: '日期选择',
|
||||||
|
icon: CalendarOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请选择日期',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'datetime',
|
||||||
|
label: '日期时间',
|
||||||
|
icon: FieldTimeOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请选择日期时间',
|
||||||
|
showTime: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'time',
|
||||||
|
label: '时间选择',
|
||||||
|
icon: ClockCircleOutlined,
|
||||||
|
category: '基础字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请选择时间',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 高级字段
|
||||||
|
{
|
||||||
|
type: 'switch',
|
||||||
|
label: '开关',
|
||||||
|
icon: SwitcherOutlined,
|
||||||
|
category: '高级字段',
|
||||||
|
defaultConfig: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
label: '滑块',
|
||||||
|
icon: SlidersOutlined,
|
||||||
|
category: '高级字段',
|
||||||
|
defaultConfig: {
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'rate',
|
||||||
|
label: '评分',
|
||||||
|
icon: StarOutlined,
|
||||||
|
category: '高级字段',
|
||||||
|
defaultConfig: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'upload',
|
||||||
|
label: '文件上传',
|
||||||
|
icon: UploadOutlined,
|
||||||
|
category: '高级字段',
|
||||||
|
defaultConfig: {
|
||||||
|
maxCount: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cascader',
|
||||||
|
label: '级联选择',
|
||||||
|
icon: ApartmentOutlined,
|
||||||
|
category: '高级字段',
|
||||||
|
defaultConfig: {
|
||||||
|
placeholder: '请选择',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '浙江',
|
||||||
|
value: 'zhejiang',
|
||||||
|
},
|
||||||
|
] as any,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 布局字段
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
label: '文字',
|
||||||
|
icon: FileTextOutlined,
|
||||||
|
category: '布局字段',
|
||||||
|
defaultConfig: {
|
||||||
|
content: '这是一段文字',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'grid',
|
||||||
|
label: '栅格布局',
|
||||||
|
icon: BorderOutlined,
|
||||||
|
category: '布局字段',
|
||||||
|
defaultConfig: {
|
||||||
|
columns: 2,
|
||||||
|
gutter: 16,
|
||||||
|
children: [[], []], // 初始化两列空数组
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
label: '分割线',
|
||||||
|
icon: MinusOutlined,
|
||||||
|
category: '布局字段',
|
||||||
|
defaultConfig: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 根据分类分组组件
|
||||||
|
export const getComponentsByCategory = () => {
|
||||||
|
const grouped: Record<string, ComponentMeta[]> = {};
|
||||||
|
|
||||||
|
COMPONENT_LIST.forEach((comp) => {
|
||||||
|
if (!grouped[comp.category]) {
|
||||||
|
grouped[comp.category] = [];
|
||||||
|
}
|
||||||
|
grouped[comp.category].push(comp);
|
||||||
|
});
|
||||||
|
|
||||||
|
return grouped;
|
||||||
|
};
|
||||||
|
|
||||||
378
frontend/src/pages/FormDesigner/index.tsx
Normal file
378
frontend/src/pages/FormDesigner/index.tsx
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器主入口
|
||||||
|
*
|
||||||
|
* 功能:
|
||||||
|
* - 左侧:组件面板
|
||||||
|
* - 中间:设计画布
|
||||||
|
* - 右侧:属性配置
|
||||||
|
* - 导入/导出 JSON Schema
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Button, Space, message, Modal } from 'antd';
|
||||||
|
import {
|
||||||
|
SaveOutlined,
|
||||||
|
DownloadOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
ClearOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { DndContext, DragEndEvent, DragOverlay, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
|
import { arrayMove } from '@dnd-kit/sortable';
|
||||||
|
import ComponentPanel from './components/ComponentPanel';
|
||||||
|
import DesignCanvas from './components/DesignCanvas';
|
||||||
|
import PropertyPanel from './components/PropertyPanel';
|
||||||
|
import FormPreview from './components/FormPreview';
|
||||||
|
import { COMPONENT_LIST } from './config';
|
||||||
|
import type { FieldConfig, FormConfig, FormSchema } from './types';
|
||||||
|
|
||||||
|
const FormDesigner: React.FC = () => {
|
||||||
|
// 表单字段列表
|
||||||
|
const [fields, setFields] = useState<FieldConfig[]>([]);
|
||||||
|
|
||||||
|
// 选中的字段
|
||||||
|
const [selectedFieldId, setSelectedFieldId] = useState<string>();
|
||||||
|
|
||||||
|
// 表单配置
|
||||||
|
const [formConfig, setFormConfig] = useState<FormConfig>({
|
||||||
|
labelAlign: 'right',
|
||||||
|
size: 'middle',
|
||||||
|
formWidth: 600, // 表单弹窗宽度(像素)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 预览模式
|
||||||
|
const [previewVisible, setPreviewVisible] = useState(false);
|
||||||
|
|
||||||
|
// 拖拽传感器
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 生成唯一 ID
|
||||||
|
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
// 处理拖拽结束
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const overId = over.id.toString();
|
||||||
|
|
||||||
|
// 从组件面板拖入新组件
|
||||||
|
if (active.data.current?.isNew) {
|
||||||
|
const componentType = active.id.toString().replace('new-', '');
|
||||||
|
const componentMeta = COMPONENT_LIST.find(c => c.type === componentType);
|
||||||
|
|
||||||
|
if (componentMeta) {
|
||||||
|
const newField: FieldConfig = {
|
||||||
|
id: generateId(),
|
||||||
|
type: componentMeta.type,
|
||||||
|
label: componentMeta.label,
|
||||||
|
name: componentType + '_' + Date.now(),
|
||||||
|
...componentMeta.defaultConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检测是否拖入栅格列
|
||||||
|
if (overId.startsWith('grid-') && overId.includes('-col-')) {
|
||||||
|
const match = overId.match(/grid-(.+)-col-(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const [, gridId, colIndexStr] = match;
|
||||||
|
const colIndex = parseInt(colIndexStr, 10);
|
||||||
|
|
||||||
|
setFields(prev => prev.map(field => {
|
||||||
|
if (field.id === gridId && field.type === 'grid') {
|
||||||
|
const newChildren = [...(field.children || [])];
|
||||||
|
if (!newChildren[colIndex]) {
|
||||||
|
newChildren[colIndex] = [];
|
||||||
|
}
|
||||||
|
newChildren[colIndex] = [...newChildren[colIndex], newField];
|
||||||
|
return { ...field, children: newChildren };
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
}));
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success(`已添加${componentMeta.label}到栅格列`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖入主画布
|
||||||
|
setFields(prev => [...prev, newField]);
|
||||||
|
setSelectedFieldId(newField.id);
|
||||||
|
message.success(`已添加${componentMeta.label}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 画布内拖拽排序
|
||||||
|
else if (over.id === 'canvas' || fields.some(f => f.id === over.id)) {
|
||||||
|
const oldIndex = fields.findIndex(f => f.id === active.id);
|
||||||
|
const newIndex = fields.findIndex(f => f.id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||||
|
setFields(arrayMove(fields, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// 选中字段
|
||||||
|
const handleSelectField = useCallback((fieldId: string) => {
|
||||||
|
setSelectedFieldId(fieldId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 删除字段(支持删除栅格内的字段)
|
||||||
|
const handleDeleteField = useCallback((fieldId: string) => {
|
||||||
|
let deleted = false;
|
||||||
|
|
||||||
|
// 递归删除函数
|
||||||
|
const deleteFieldRecursive = (fieldList: FieldConfig[]): FieldConfig[] => {
|
||||||
|
return fieldList
|
||||||
|
.filter(f => {
|
||||||
|
if (f.id === fieldId) {
|
||||||
|
deleted = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(f => {
|
||||||
|
// 如果是栅格布局,递归处理其子字段
|
||||||
|
if (f.type === 'grid' && f.children) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: f.children.map(columnFields =>
|
||||||
|
deleteFieldRecursive(columnFields)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setFields(prev => deleteFieldRecursive(prev));
|
||||||
|
|
||||||
|
if (deleted) {
|
||||||
|
if (selectedFieldId === fieldId) {
|
||||||
|
setSelectedFieldId(undefined);
|
||||||
|
}
|
||||||
|
message.success('已删除字段');
|
||||||
|
}
|
||||||
|
}, [selectedFieldId]);
|
||||||
|
|
||||||
|
// 更新字段属性(支持更新栅格内的字段)
|
||||||
|
const handleFieldChange = useCallback((updatedField: FieldConfig) => {
|
||||||
|
// 递归更新函数
|
||||||
|
const updateFieldRecursive = (fieldList: FieldConfig[]): FieldConfig[] => {
|
||||||
|
return fieldList.map(f => {
|
||||||
|
if (f.id === updatedField.id) {
|
||||||
|
return updatedField;
|
||||||
|
}
|
||||||
|
// 如果是栅格布局,递归处理其子字段
|
||||||
|
if (f.type === 'grid' && f.children) {
|
||||||
|
return {
|
||||||
|
...f,
|
||||||
|
children: f.children.map(columnFields =>
|
||||||
|
updateFieldRecursive(columnFields)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
setFields(prev => updateFieldRecursive(prev));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 保存表单
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
message.warning('表单为空,请先添加字段');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: FormSchema = {
|
||||||
|
formConfig,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('保存的表单 Schema:', JSON.stringify(schema, null, 2));
|
||||||
|
message.success('表单已保存!请查看控制台');
|
||||||
|
|
||||||
|
// TODO: 调用后端 API 保存
|
||||||
|
}, [fields, formConfig]);
|
||||||
|
|
||||||
|
// 导出 JSON
|
||||||
|
const handleExport = useCallback(() => {
|
||||||
|
if (fields.length === 0) {
|
||||||
|
message.warning('表单为空,无法导出');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema: FormSchema = {
|
||||||
|
formConfig,
|
||||||
|
fields,
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataStr = JSON.stringify(schema, null, 2);
|
||||||
|
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
||||||
|
const exportFileDefaultName = `form-schema-${Date.now()}.json`;
|
||||||
|
|
||||||
|
const linkElement = document.createElement('a');
|
||||||
|
linkElement.setAttribute('href', dataUri);
|
||||||
|
linkElement.setAttribute('download', exportFileDefaultName);
|
||||||
|
linkElement.click();
|
||||||
|
|
||||||
|
message.success('JSON Schema 已导出');
|
||||||
|
}, [fields, formConfig]);
|
||||||
|
|
||||||
|
// 导入 JSON
|
||||||
|
const handleImport = useCallback(() => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json';
|
||||||
|
|
||||||
|
input.onchange = (e: any) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event: any) => {
|
||||||
|
try {
|
||||||
|
const schema: FormSchema = JSON.parse(event.target.result);
|
||||||
|
|
||||||
|
if (!schema.fields || !Array.isArray(schema.fields)) {
|
||||||
|
message.error('无效的表单 Schema 格式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setFields(schema.fields);
|
||||||
|
setFormConfig(schema.formConfig || formConfig);
|
||||||
|
setSelectedFieldId(undefined);
|
||||||
|
message.success('JSON Schema 导入成功');
|
||||||
|
} catch (error) {
|
||||||
|
message.error('JSON 格式错误');
|
||||||
|
console.error('导入错误:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
}, [formConfig]);
|
||||||
|
|
||||||
|
// 清空表单
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认清空',
|
||||||
|
content: '确定要清空所有字段吗?此操作不可恢复。',
|
||||||
|
onOk: () => {
|
||||||
|
setFields([]);
|
||||||
|
setSelectedFieldId(undefined);
|
||||||
|
message.info('表单已清空');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 递归查找选中的字段(包括栅格内的字段)
|
||||||
|
const findFieldById = (fieldList: FieldConfig[], id?: string): FieldConfig | undefined => {
|
||||||
|
if (!id) return undefined;
|
||||||
|
|
||||||
|
for (const field of fieldList) {
|
||||||
|
if (field.id === id) {
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
// 如果是栅格布局,递归查找子字段
|
||||||
|
if (field.type === 'grid' && field.children) {
|
||||||
|
for (const columnFields of field.children) {
|
||||||
|
const found = findFieldById(columnFields, id);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedField = findFieldById(fields, selectedFieldId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: '100vh', width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
{/* 顶部工具栏 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: '#fff',
|
||||||
|
borderBottom: '1px solid #e8e8e8',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ margin: 0, fontSize: '18px', fontWeight: 500 }}>
|
||||||
|
表单设计器
|
||||||
|
</h2>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<UploadOutlined />} onClick={handleImport}>
|
||||||
|
导入 JSON
|
||||||
|
</Button>
|
||||||
|
<Button icon={<DownloadOutlined />} onClick={handleExport}>
|
||||||
|
导出 JSON
|
||||||
|
</Button>
|
||||||
|
<Button icon={<EyeOutlined />} onClick={() => setPreviewVisible(true)}>
|
||||||
|
预览
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ClearOutlined />} danger onClick={handleClear}>
|
||||||
|
清空
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 主体区域 */}
|
||||||
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
||||||
|
{/* 左侧组件面板 */}
|
||||||
|
<div style={{ width: 280 }}>
|
||||||
|
<ComponentPanel />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中间设计画布 */}
|
||||||
|
<DesignCanvas
|
||||||
|
fields={fields}
|
||||||
|
selectedFieldId={selectedFieldId}
|
||||||
|
onSelectField={handleSelectField}
|
||||||
|
onDeleteField={handleDeleteField}
|
||||||
|
labelAlign={formConfig.labelAlign}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 右侧属性面板 */}
|
||||||
|
<PropertyPanel
|
||||||
|
selectedField={selectedField}
|
||||||
|
formConfig={formConfig}
|
||||||
|
onFieldChange={handleFieldChange}
|
||||||
|
onFormConfigChange={setFormConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragOverlay>{/* 拖拽时的视觉反馈 */}</DragOverlay>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
|
{/* 预览模态框 */}
|
||||||
|
<Modal
|
||||||
|
title="表单预览"
|
||||||
|
open={previewVisible}
|
||||||
|
onCancel={() => setPreviewVisible(false)}
|
||||||
|
width={formConfig.formWidth || 600}
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<FormPreview fields={fields} formConfig={formConfig} />
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FormDesigner;
|
||||||
135
frontend/src/pages/FormDesigner/styles.css
Normal file
135
frontend/src/pages/FormDesigner/styles.css
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 组件面板 */
|
||||||
|
.form-designer-component-panel {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fafafa;
|
||||||
|
border-right: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-component-panel-header {
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-component-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-component-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
user-select: none;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-component-item:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #f0f5ff;
|
||||||
|
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-component-item:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 设计画布 */
|
||||||
|
.form-designer-canvas {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #fff;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-canvas-header {
|
||||||
|
padding: 16px 24px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-canvas-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-canvas-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 400px;
|
||||||
|
border: 2px dashed #d9d9d9;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-list {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 字段项 */
|
||||||
|
.form-designer-field-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid #e8e8e8;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-item:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-item.selected {
|
||||||
|
border-color: #1890ff;
|
||||||
|
background: #f0f5ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-drag-handle {
|
||||||
|
color: #8c8c8c;
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: move;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-drag-handle:hover {
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-content {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-actions {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-designer-field-item:hover .form-designer-field-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
69
frontend/src/pages/FormDesigner/types.ts
Normal file
69
frontend/src/pages/FormDesigner/types.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* 表单设计器类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 字段类型
|
||||||
|
export type FieldType =
|
||||||
|
| 'input' // 单行文本
|
||||||
|
| 'textarea' // 多行文本
|
||||||
|
| 'number' // 数字输入
|
||||||
|
| 'select' // 下拉选择
|
||||||
|
| 'radio' // 单选按钮组
|
||||||
|
| 'checkbox' // 多选框组
|
||||||
|
| 'date' // 日期选择
|
||||||
|
| 'datetime' // 日期时间选择
|
||||||
|
| 'time' // 时间选择
|
||||||
|
| 'switch' // 开关
|
||||||
|
| 'slider' // 滑块
|
||||||
|
| 'rate' // 评分
|
||||||
|
| 'upload' // 文件上传
|
||||||
|
| 'cascader' // 级联选择
|
||||||
|
| 'text' // 纯文本
|
||||||
|
| 'grid' // 栅格布局
|
||||||
|
| 'divider'; // 分割线
|
||||||
|
|
||||||
|
// 选项类型(用于 select, radio, checkbox)
|
||||||
|
export interface FieldOption {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字段配置
|
||||||
|
export interface FieldConfig {
|
||||||
|
id: string;
|
||||||
|
type: FieldType;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
placeholder?: string;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
defaultValue?: any;
|
||||||
|
options?: FieldOption[];
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
rows?: number;
|
||||||
|
showTime?: boolean;
|
||||||
|
multiple?: boolean;
|
||||||
|
accept?: string;
|
||||||
|
maxCount?: number;
|
||||||
|
span?: number; // 栅格占位格数(该字段在栅格中占几列)
|
||||||
|
content?: string; // 文本内容(用于 text 组件)
|
||||||
|
columns?: number; // 栅格列数(用于 grid 组件)
|
||||||
|
gutter?: number; // 栅格间距(用于 grid 组件)
|
||||||
|
children?: FieldConfig[][]; // 子字段(用于容器组件,二维数组,每个子数组代表一列)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单配置
|
||||||
|
export interface FormConfig {
|
||||||
|
title?: string;
|
||||||
|
formWidth?: number; // 表单弹窗宽度(像素)
|
||||||
|
labelAlign?: 'left' | 'right' | 'top';
|
||||||
|
size?: 'small' | 'middle' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单 Schema
|
||||||
|
export interface FormSchema {
|
||||||
|
formConfig: FormConfig;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ const DeploymentConfigList = lazy(() => import('../pages/Deploy/Deployment/List'
|
|||||||
const JenkinsManagerList = lazy(() => import('../pages/Deploy/JenkinsManager/List'));
|
const JenkinsManagerList = lazy(() => import('../pages/Deploy/JenkinsManager/List'));
|
||||||
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
const GitManagerList = lazy(() => import('../pages/Deploy/GitManager/List'));
|
||||||
const External = lazy(() => import('../pages/Deploy/External'));
|
const External = lazy(() => import('../pages/Deploy/External'));
|
||||||
|
const FormDesigner = lazy(() => import('../pages/FormDesigner'));
|
||||||
|
|
||||||
// Workflow2 相关路由已迁移到 Workflow,删除旧路由
|
// Workflow2 相关路由已迁移到 Workflow,删除旧路由
|
||||||
|
|
||||||
@ -216,6 +217,14 @@ const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'form-designer',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<LoadingComponent/>}>
|
||||||
|
<FormDesigner/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'log-stream/:processInstanceId',
|
path: 'log-stream/:processInstanceId',
|
||||||
element: (
|
element: (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user