表单设计器

This commit is contained in:
dengqichen 2025-10-23 21:30:24 +08:00
parent e0e1f4e989
commit 46232db651
14 changed files with 2034 additions and 0 deletions

View File

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

View File

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

View 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;

View 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;

View 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;

View 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;

View 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;

View File

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

View 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;

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

View 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;

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

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

View File

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