|
|
@@ -0,0 +1,814 @@
|
|
|
+import React, { useState } from 'react';
|
|
|
+import { useNavigate, useSearchParams } from 'react-router-dom';
|
|
|
+import { DndProvider, useDrag, useDrop } from 'react-dnd';
|
|
|
+import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
|
+import {
|
|
|
+ Button,
|
|
|
+ Input,
|
|
|
+ Select,
|
|
|
+ DatePicker,
|
|
|
+ InputNumber,
|
|
|
+ Switch,
|
|
|
+ Radio,
|
|
|
+ Checkbox,
|
|
|
+ Card,
|
|
|
+ Space,
|
|
|
+ message,
|
|
|
+ Modal,
|
|
|
+ Form as AntdForm,
|
|
|
+ Collapse,
|
|
|
+ Divider
|
|
|
+} from 'antd';
|
|
|
+import {
|
|
|
+ ArrowLeft,
|
|
|
+ Save,
|
|
|
+ Plus,
|
|
|
+ Trash2,
|
|
|
+ MoveUp,
|
|
|
+ MoveDown,
|
|
|
+ Eye,
|
|
|
+ Code,
|
|
|
+ Undo2,
|
|
|
+ Redo2,
|
|
|
+ ZoomIn,
|
|
|
+ ZoomOut,
|
|
|
+ Grid,
|
|
|
+ Type,
|
|
|
+ Calendar,
|
|
|
+ Hash,
|
|
|
+ ToggleLeft,
|
|
|
+ CheckSquare,
|
|
|
+ Radio as RadioIcon,
|
|
|
+ Upload,
|
|
|
+ Layout,
|
|
|
+ FileText
|
|
|
+} from 'lucide-react';
|
|
|
+import { toast } from 'sonner';
|
|
|
+import * as FormApi from '../api/bpm/form';
|
|
|
+
|
|
|
+const { TextArea } = Input;
|
|
|
+const { Panel } = Collapse;
|
|
|
+
|
|
|
+// 表单字段类型
|
|
|
+type FieldType =
|
|
|
+ | 'input'
|
|
|
+ | 'textarea'
|
|
|
+ | 'password'
|
|
|
+ | 'select'
|
|
|
+ | 'date'
|
|
|
+ | 'daterange'
|
|
|
+ | 'number'
|
|
|
+ | 'switch'
|
|
|
+ | 'radio'
|
|
|
+ | 'checkbox'
|
|
|
+ | 'upload'
|
|
|
+ | 'slider'
|
|
|
+ | 'rate'
|
|
|
+ | 'cascader'
|
|
|
+ | 'treeselect'
|
|
|
+ | 'timepicker'
|
|
|
+ | 'card'
|
|
|
+ | 'grid'
|
|
|
+ | 'tabs'
|
|
|
+ | 'collapse';
|
|
|
+
|
|
|
+interface FormField {
|
|
|
+ id: string;
|
|
|
+ type: FieldType;
|
|
|
+ label: string;
|
|
|
+ name: string;
|
|
|
+ required?: boolean;
|
|
|
+ placeholder?: string;
|
|
|
+ options?: { label: string; value: string }[];
|
|
|
+ defaultValue?: any;
|
|
|
+ span?: number; // 栅格布局
|
|
|
+ rules?: any[];
|
|
|
+ disabled?: boolean;
|
|
|
+ hidden?: boolean;
|
|
|
+ width?: string | number;
|
|
|
+ size?: 'small' | 'middle' | 'large';
|
|
|
+}
|
|
|
+
|
|
|
+// 组件库配置
|
|
|
+const componentLibrary = {
|
|
|
+ '输入控件': [
|
|
|
+ { type: 'input', label: '输入框', icon: Type },
|
|
|
+ { type: 'textarea', label: '多行输入', icon: FileText },
|
|
|
+ { type: 'password', label: '密码输入', icon: Type },
|
|
|
+ { type: 'number', label: '数字输入', icon: Hash },
|
|
|
+ { type: 'select', label: '选择框', icon: CheckSquare },
|
|
|
+ { type: 'date', label: '日期选择', icon: Calendar },
|
|
|
+ { type: 'daterange', label: '日期范围', icon: Calendar },
|
|
|
+ { type: 'timepicker', label: '时间选择', icon: Calendar },
|
|
|
+ { type: 'switch', label: '开关', icon: ToggleLeft },
|
|
|
+ { type: 'radio', label: '单选框组', icon: RadioIcon },
|
|
|
+ { type: 'checkbox', label: '复选框组', icon: CheckSquare },
|
|
|
+ { type: 'upload', label: '上传', icon: Upload },
|
|
|
+ { type: 'slider', label: '滑动条', icon: Hash },
|
|
|
+ { type: 'rate', label: '评分器', icon: Hash },
|
|
|
+ { type: 'cascader', label: '联级选择', icon: CheckSquare },
|
|
|
+ { type: 'treeselect', label: '树选择', icon: CheckSquare },
|
|
|
+ ],
|
|
|
+ '布局组件': [
|
|
|
+ { type: 'card', label: '卡片', icon: Layout },
|
|
|
+ { type: 'grid', label: '网格布局', icon: Grid },
|
|
|
+ { type: 'tabs', label: '选项卡', icon: FileText },
|
|
|
+ { type: 'collapse', label: '折叠面板', icon: FileText },
|
|
|
+ ],
|
|
|
+};
|
|
|
+
|
|
|
+// 可拖拽的组件项
|
|
|
+const DraggableComponent: React.FC<{
|
|
|
+ type: FieldType;
|
|
|
+ label: string;
|
|
|
+ icon: any;
|
|
|
+ onDrag: (type: FieldType) => void;
|
|
|
+}> = ({ type, label, icon: Icon, onDrag }) => {
|
|
|
+ const [{ isDragging }, drag] = useDrag({
|
|
|
+ type: 'component',
|
|
|
+ item: { type },
|
|
|
+ collect: (monitor) => ({
|
|
|
+ isDragging: monitor.isDragging(),
|
|
|
+ }),
|
|
|
+ end: (item, monitor) => {
|
|
|
+ if (monitor.didDrop()) {
|
|
|
+ onDrag(item.type);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ });
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ ref={drag}
|
|
|
+ className={`flex items-center gap-2 p-2 rounded cursor-move hover:bg-gray-100 transition-colors ${
|
|
|
+ isDragging ? 'opacity-50' : ''
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <Icon className="w-4 h-4 text-gray-600" />
|
|
|
+ <span className="text-sm text-gray-700">{label}</span>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// 可放置的画布区域
|
|
|
+const CanvasDropZone: React.FC<{
|
|
|
+ children: React.ReactNode;
|
|
|
+ onDrop: (type: FieldType) => void;
|
|
|
+}> = ({ children, onDrop }) => {
|
|
|
+ const [{ isOver }, drop] = useDrop({
|
|
|
+ accept: 'component',
|
|
|
+ drop: (item: { type: FieldType }) => {
|
|
|
+ onDrop(item.type);
|
|
|
+ },
|
|
|
+ collect: (monitor) => ({
|
|
|
+ isOver: monitor.isOver(),
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ ref={drop}
|
|
|
+ className={`min-h-[600px] p-8 transition-colors ${
|
|
|
+ isOver ? 'bg-blue-50 border-2 border-blue-300 border-dashed' : 'bg-white'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ {children}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+// 字段渲染组件
|
|
|
+const FieldRenderer: React.FC<{
|
|
|
+ field: FormField;
|
|
|
+ isSelected: boolean;
|
|
|
+ onSelect: () => void;
|
|
|
+ onDelete: () => void;
|
|
|
+}> = ({ field, isSelected, onSelect, onDelete }) => {
|
|
|
+ const renderField = () => {
|
|
|
+ const commonProps = {
|
|
|
+ placeholder: field.placeholder,
|
|
|
+ disabled: field.disabled,
|
|
|
+ size: field.size || 'middle',
|
|
|
+ };
|
|
|
+
|
|
|
+ switch (field.type) {
|
|
|
+ case 'input':
|
|
|
+ return <Input {...commonProps} />;
|
|
|
+ case 'textarea':
|
|
|
+ return <TextArea {...commonProps} rows={4} />;
|
|
|
+ case 'password':
|
|
|
+ return <Input.Password {...commonProps} />;
|
|
|
+ case 'select':
|
|
|
+ return (
|
|
|
+ <Select {...commonProps}>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Select.Option key={idx} value={opt.value}>{opt.label}</Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ );
|
|
|
+ case 'date':
|
|
|
+ return <DatePicker {...commonProps} className="w-full" />;
|
|
|
+ case 'daterange':
|
|
|
+ return <DatePicker.RangePicker {...commonProps} className="w-full" />;
|
|
|
+ case 'number':
|
|
|
+ return <InputNumber {...commonProps} className="w-full" />;
|
|
|
+ case 'switch':
|
|
|
+ return <Switch disabled={field.disabled} />;
|
|
|
+ case 'radio':
|
|
|
+ return (
|
|
|
+ <Radio.Group>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Radio key={idx} value={opt.value}>{opt.label}</Radio>
|
|
|
+ ))}
|
|
|
+ </Radio.Group>
|
|
|
+ );
|
|
|
+ case 'checkbox':
|
|
|
+ return (
|
|
|
+ <Checkbox.Group>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Checkbox key={idx} value={opt.value}>{opt.label}</Checkbox>
|
|
|
+ ))}
|
|
|
+ </Checkbox.Group>
|
|
|
+ );
|
|
|
+ default:
|
|
|
+ return <Input {...commonProps} />;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={`relative p-4 border-2 rounded-lg transition-all cursor-pointer ${
|
|
|
+ isSelected
|
|
|
+ ? 'border-blue-500 bg-blue-50 shadow-md'
|
|
|
+ : 'border-transparent hover:border-gray-300 hover:bg-gray-50'
|
|
|
+ }`}
|
|
|
+ onClick={onSelect}
|
|
|
+ >
|
|
|
+ <AntdForm.Item
|
|
|
+ label={field.label}
|
|
|
+ required={field.required}
|
|
|
+ className="mb-0"
|
|
|
+ >
|
|
|
+ {renderField()}
|
|
|
+ </AntdForm.Item>
|
|
|
+ {isSelected && (
|
|
|
+ <div className="absolute top-2 right-2 flex gap-1">
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<Trash2 className="w-3 h-3" />}
|
|
|
+ onClick={(e) => {
|
|
|
+ e.stopPropagation();
|
|
|
+ onDelete();
|
|
|
+ }}
|
|
|
+ danger
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default function FormDesigner() {
|
|
|
+ const navigate = useNavigate();
|
|
|
+ const [searchParams] = useSearchParams();
|
|
|
+ const formType = searchParams.get('type') || 'create';
|
|
|
+ const formId = searchParams.get('id');
|
|
|
+
|
|
|
+ const [formName, setFormName] = useState('');
|
|
|
+ const [formRemark, setFormRemark] = useState('');
|
|
|
+ const [fields, setFields] = useState<FormField[]>([]);
|
|
|
+ const [selectedFieldId, setSelectedFieldId] = useState<string | null>(null);
|
|
|
+ const [saving, setSaving] = useState(false);
|
|
|
+ const [previewVisible, setPreviewVisible] = useState(false);
|
|
|
+ const [codeVisible, setCodeVisible] = useState(false);
|
|
|
+ const [previewForm] = AntdForm.useForm();
|
|
|
+
|
|
|
+ // 加载表单数据
|
|
|
+ React.useEffect(() => {
|
|
|
+ if (formId && (formType === 'update' || formType === 'copy')) {
|
|
|
+ loadFormData(Number(formId));
|
|
|
+ }
|
|
|
+ }, [formId, formType]);
|
|
|
+
|
|
|
+ const loadFormData = async (id: number) => {
|
|
|
+ try {
|
|
|
+ const data = await FormApi.getForm(id);
|
|
|
+ setFormName(data.name || '');
|
|
|
+ setFormRemark(data.remark || '');
|
|
|
+
|
|
|
+ let parsedFields: FormField[] = [];
|
|
|
+ if (data.fields) {
|
|
|
+ try {
|
|
|
+ const fieldsData = typeof data.fields === 'string'
|
|
|
+ ? JSON.parse(data.fields)
|
|
|
+ : data.fields;
|
|
|
+
|
|
|
+ if (Array.isArray(fieldsData)) {
|
|
|
+ parsedFields = fieldsData.map((field: any, index: number) => ({
|
|
|
+ id: field.id || `field_${index}`,
|
|
|
+ type: field.type || 'input',
|
|
|
+ label: field.label || field.title || '',
|
|
|
+ name: field.name || field.field || '',
|
|
|
+ required: field.required || false,
|
|
|
+ placeholder: field.placeholder || '',
|
|
|
+ options: field.options || [],
|
|
|
+ defaultValue: field.defaultValue,
|
|
|
+ span: field.span,
|
|
|
+ rules: field.rules,
|
|
|
+ disabled: field.disabled,
|
|
|
+ hidden: field.hidden,
|
|
|
+ width: field.width,
|
|
|
+ size: field.size,
|
|
|
+ }));
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('解析字段数据失败:', e);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ setFields(parsedFields);
|
|
|
+ } catch (error: any) {
|
|
|
+ toast.error(error.message || '加载表单数据失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 添加字段
|
|
|
+ const addField = (type: FieldType) => {
|
|
|
+ const fieldConfig: Record<FieldType, Partial<FormField>> = {
|
|
|
+ input: { placeholder: '请输入' },
|
|
|
+ textarea: { placeholder: '请输入内容' },
|
|
|
+ password: { placeholder: '请输入密码' },
|
|
|
+ select: {
|
|
|
+ placeholder: '请选择',
|
|
|
+ options: [{ label: '选项1', value: 'option1' }, { label: '选项2', value: 'option2' }]
|
|
|
+ },
|
|
|
+ date: { placeholder: '请选择日期' },
|
|
|
+ daterange: { placeholder: ['开始日期', '结束日期'] },
|
|
|
+ number: { placeholder: '请输入数字' },
|
|
|
+ switch: {},
|
|
|
+ radio: {
|
|
|
+ options: [{ label: '选项1', value: 'option1' }, { label: '选项2', value: 'option2' }]
|
|
|
+ },
|
|
|
+ checkbox: {
|
|
|
+ options: [{ label: '选项1', value: 'option1' }, { label: '选项2', value: 'option2' }]
|
|
|
+ },
|
|
|
+ upload: {},
|
|
|
+ slider: {},
|
|
|
+ rate: {},
|
|
|
+ cascader: {},
|
|
|
+ treeselect: {},
|
|
|
+ timepicker: { placeholder: '请选择时间' },
|
|
|
+ card: {},
|
|
|
+ grid: {},
|
|
|
+ tabs: {},
|
|
|
+ collapse: {},
|
|
|
+ };
|
|
|
+
|
|
|
+ const newField: FormField = {
|
|
|
+ id: `field_${Date.now()}`,
|
|
|
+ type,
|
|
|
+ label: `字段${fields.length + 1}`,
|
|
|
+ name: `field${fields.length + 1}`,
|
|
|
+ required: false,
|
|
|
+ ...fieldConfig[type],
|
|
|
+ };
|
|
|
+
|
|
|
+ setFields([...fields, newField]);
|
|
|
+ setSelectedFieldId(newField.id);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 删除字段
|
|
|
+ const deleteField = (id: string) => {
|
|
|
+ setFields(fields.filter(f => f.id !== id));
|
|
|
+ if (selectedFieldId === id) {
|
|
|
+ setSelectedFieldId(null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 更新字段
|
|
|
+ const updateField = (id: string, updates: Partial<FormField>) => {
|
|
|
+ setFields(fields.map(f => f.id === id ? { ...f, ...updates } : f));
|
|
|
+ };
|
|
|
+
|
|
|
+ // 移动字段
|
|
|
+ const moveField = (id: string, direction: 'up' | 'down') => {
|
|
|
+ const index = fields.findIndex(f => f.id === id);
|
|
|
+ if (index === -1) return;
|
|
|
+
|
|
|
+ const newFields = [...fields];
|
|
|
+ if (direction === 'up' && index > 0) {
|
|
|
+ [newFields[index - 1], newFields[index]] = [newFields[index], newFields[index - 1]];
|
|
|
+ } else if (direction === 'down' && index < fields.length - 1) {
|
|
|
+ [newFields[index], newFields[index + 1]] = [newFields[index + 1], newFields[index]];
|
|
|
+ }
|
|
|
+ setFields(newFields);
|
|
|
+ };
|
|
|
+
|
|
|
+ // 保存表单
|
|
|
+ const handleSave = async () => {
|
|
|
+ if (!formName.trim()) {
|
|
|
+ message.error('请输入表单名称');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (fields.length === 0) {
|
|
|
+ message.error('请至少添加一个字段');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ setSaving(true);
|
|
|
+ try {
|
|
|
+ const formData: FormApi.FormVO = {
|
|
|
+ name: formName,
|
|
|
+ remark: formRemark,
|
|
|
+ conf: JSON.stringify({ fields }),
|
|
|
+ fields: JSON.stringify(fields) as any,
|
|
|
+ status: 0,
|
|
|
+ };
|
|
|
+
|
|
|
+ if (formType === 'create' || formType === 'copy') {
|
|
|
+ await FormApi.createForm(formData);
|
|
|
+ toast.success('创建成功');
|
|
|
+ } else if (formType === 'update' && formId) {
|
|
|
+ formData.id = Number(formId);
|
|
|
+ await FormApi.updateForm(formData);
|
|
|
+ toast.success('更新成功');
|
|
|
+ }
|
|
|
+
|
|
|
+ navigate(-1);
|
|
|
+ } catch (error: any) {
|
|
|
+ toast.error(error.message || '保存失败');
|
|
|
+ } finally {
|
|
|
+ setSaving(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const selectedField = fields.find(f => f.id === selectedFieldId);
|
|
|
+ const selectedIndex = fields.findIndex(f => f.id === selectedFieldId);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <DndProvider backend={HTML5Backend}>
|
|
|
+ <div className="h-screen flex flex-col bg-gray-50">
|
|
|
+ {/* 顶部工具栏 */}
|
|
|
+ <div className="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shadow-sm">
|
|
|
+ <div className="flex items-center gap-4">
|
|
|
+ <Button
|
|
|
+ icon={<ArrowLeft className="w-4 h-4" />}
|
|
|
+ onClick={() => navigate(-1)}
|
|
|
+ >
|
|
|
+ 返回
|
|
|
+ </Button>
|
|
|
+ <Divider type="vertical" />
|
|
|
+ {/* <div className="flex items-center gap-2">
|
|
|
+ <span className="text-sm text-gray-600">表单名称:</span>
|
|
|
+ <Input
|
|
|
+ value={formName}
|
|
|
+ onChange={(e) => setFormName(e.target.value)}
|
|
|
+ placeholder="请输入表单名称"
|
|
|
+ className="w-48"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-sm text-gray-600">备注:</span>
|
|
|
+ <Input
|
|
|
+ value={formRemark}
|
|
|
+ onChange={(e) => setFormRemark(e.target.value)}
|
|
|
+ placeholder="请输入备注"
|
|
|
+ className="w-48"
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div> */}
|
|
|
+ </div>
|
|
|
+ <Space>
|
|
|
+ <Button icon={<Eye className="w-4 h-4" />} onClick={() => setPreviewVisible(true)}>
|
|
|
+ 预览
|
|
|
+ </Button>
|
|
|
+ <Button icon={<Code className="w-4 h-4" />} onClick={() => setCodeVisible(true)}>
|
|
|
+ 代码
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="primary"
|
|
|
+ icon={<Save className="w-4 h-4" />}
|
|
|
+ onClick={handleSave}
|
|
|
+ loading={saving}
|
|
|
+ >
|
|
|
+ 保存
|
|
|
+ </Button>
|
|
|
+ </Space>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex-1 flex overflow-hidden">
|
|
|
+ {/* 左侧:组件库 */}
|
|
|
+ <div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
|
|
+ <div className="p-4 border-b border-gray-200">
|
|
|
+ <h3 className="text-sm font-semibold text-gray-700">组件</h3>
|
|
|
+ </div>
|
|
|
+ <div className="p-4">
|
|
|
+ {Object.entries(componentLibrary).map(([category, components]) => (
|
|
|
+ <Collapse key={category} ghost className="mb-2">
|
|
|
+ <Panel header={category} key={category}>
|
|
|
+ <div className="space-y-1">
|
|
|
+ {components.map((comp) => (
|
|
|
+ <DraggableComponent
|
|
|
+ key={comp.type}
|
|
|
+ type={comp.type}
|
|
|
+ label={comp.label}
|
|
|
+ icon={comp.icon}
|
|
|
+ onDrag={addField}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </Panel>
|
|
|
+ </Collapse>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 中间:设计画布 */}
|
|
|
+ <div className="flex-1 overflow-y-auto">
|
|
|
+ <CanvasDropZone onDrop={addField}>
|
|
|
+ <div className="max-w-4xl mx-auto">
|
|
|
+ <Card
|
|
|
+ title="表单设计"
|
|
|
+ className="min-h-[600px]"
|
|
|
+ extra={
|
|
|
+ <Space>
|
|
|
+ <Button type="text" size="small" icon={<Undo2 className="w-4 h-4" />} />
|
|
|
+ <Button type="text" size="small" icon={<Redo2 className="w-4 h-4" />} />
|
|
|
+ <Button type="text" size="small" icon={<ZoomOut className="w-4 h-4" />} />
|
|
|
+ <Button type="text" size="small" icon={<ZoomIn className="w-4 h-4" />} />
|
|
|
+ </Space>
|
|
|
+ }
|
|
|
+ >
|
|
|
+ {fields.length === 0 ? (
|
|
|
+ <div className="text-center text-gray-400 py-20">
|
|
|
+ <p className="text-sm mb-2">从左侧拖拽组件到此处开始设计</p>
|
|
|
+ {/* <p className="text-xs text-gray-400">
|
|
|
+ Selection ⌘ + Click / ⇧ + Click / ⌘ + A<br />
|
|
|
+ Copy ⌘ + C / Paste ⌘ + V<br />
|
|
|
+ Delete ⌫
|
|
|
+ </p> */}
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="space-y-4">
|
|
|
+ {fields.map((field, index) => (
|
|
|
+ <FieldRenderer
|
|
|
+ key={field.id}
|
|
|
+ field={field}
|
|
|
+ isSelected={selectedFieldId === field.id}
|
|
|
+ onSelect={() => setSelectedFieldId(field.id)}
|
|
|
+ onDelete={() => deleteField(field.id)}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Card>
|
|
|
+ </div>
|
|
|
+ </CanvasDropZone>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 右侧:属性配置面板 */}
|
|
|
+ <div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
|
|
|
+ <div className="p-4 border-b border-gray-200">
|
|
|
+ <h3 className="text-sm font-semibold text-gray-700">属性配置</h3>
|
|
|
+ </div>
|
|
|
+ {selectedField ? (
|
|
|
+ <div className="p-4 space-y-4">
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">字段标签</label>
|
|
|
+ <Input
|
|
|
+ value={selectedField.label}
|
|
|
+ onChange={(e) => updateField(selectedField.id, { label: e.target.value })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">字段名称</label>
|
|
|
+ <Input
|
|
|
+ value={selectedField.name}
|
|
|
+ onChange={(e) => updateField(selectedField.id, { name: e.target.value })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">占位符</label>
|
|
|
+ <Input
|
|
|
+ value={selectedField.placeholder}
|
|
|
+ onChange={(e) => updateField(selectedField.id, { placeholder: e.target.value })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <label className="text-xs text-gray-600">必填</label>
|
|
|
+ <Switch
|
|
|
+ checked={selectedField.required}
|
|
|
+ onChange={(checked) => updateField(selectedField.id, { required: checked })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <label className="text-xs text-gray-600">禁用</label>
|
|
|
+ <Switch
|
|
|
+ checked={selectedField.disabled}
|
|
|
+ onChange={(checked) => updateField(selectedField.id, { disabled: checked })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center justify-between">
|
|
|
+ <label className="text-xs text-gray-600">隐藏</label>
|
|
|
+ <Switch
|
|
|
+ checked={selectedField.hidden}
|
|
|
+ onChange={(checked) => updateField(selectedField.id, { hidden: checked })}
|
|
|
+ size="small"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">尺寸</label>
|
|
|
+ <Select
|
|
|
+ value={selectedField.size || 'middle'}
|
|
|
+ onChange={(value) => updateField(selectedField.id, { size: value })}
|
|
|
+ size="small"
|
|
|
+ className="w-full"
|
|
|
+ >
|
|
|
+ <Select.Option value="small">小</Select.Option>
|
|
|
+ <Select.Option value="middle">中</Select.Option>
|
|
|
+ <Select.Option value="large">大</Select.Option>
|
|
|
+ </Select>
|
|
|
+ </div>
|
|
|
+ {(selectedField.type === 'select' ||
|
|
|
+ selectedField.type === 'radio' ||
|
|
|
+ selectedField.type === 'checkbox') && (
|
|
|
+ <div>
|
|
|
+ <label className="text-xs text-gray-600 mb-1 block">选项</label>
|
|
|
+ <div className="space-y-2">
|
|
|
+ {(selectedField.options || []).map((option, idx) => (
|
|
|
+ <div key={idx} className="flex gap-2">
|
|
|
+ <Input
|
|
|
+ value={option.label}
|
|
|
+ onChange={(e) => {
|
|
|
+ const newOptions = [...(selectedField.options || [])];
|
|
|
+ newOptions[idx].label = e.target.value;
|
|
|
+ updateField(selectedField.id, { options: newOptions });
|
|
|
+ }}
|
|
|
+ placeholder="标签"
|
|
|
+ size="small"
|
|
|
+ className="flex-1"
|
|
|
+ />
|
|
|
+ <Input
|
|
|
+ value={option.value}
|
|
|
+ onChange={(e) => {
|
|
|
+ const newOptions = [...(selectedField.options || [])];
|
|
|
+ newOptions[idx].value = e.target.value;
|
|
|
+ updateField(selectedField.id, { options: newOptions });
|
|
|
+ }}
|
|
|
+ placeholder="值"
|
|
|
+ size="small"
|
|
|
+ className="flex-1"
|
|
|
+ />
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ danger
|
|
|
+ icon={<Trash2 className="w-3 h-3" />}
|
|
|
+ onClick={() => {
|
|
|
+ const newOptions = selectedField.options?.filter((_, i) => i !== idx) || [];
|
|
|
+ updateField(selectedField.id, { options: newOptions });
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ <Button
|
|
|
+ type="dashed"
|
|
|
+ size="small"
|
|
|
+ block
|
|
|
+ icon={<Plus className="w-3 h-3" />}
|
|
|
+ onClick={() => {
|
|
|
+ const newOptions = [
|
|
|
+ ...(selectedField.options || []),
|
|
|
+ { label: `选项${(selectedField.options?.length || 0) + 1}`, value: `option${(selectedField.options?.length || 0) + 1}` }
|
|
|
+ ];
|
|
|
+ updateField(selectedField.id, { options: newOptions });
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ 添加选项
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ <Divider />
|
|
|
+ <div className="flex gap-2">
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<MoveUp className="w-3 h-3" />}
|
|
|
+ onClick={() => moveField(selectedField.id, 'up')}
|
|
|
+ disabled={selectedIndex === 0}
|
|
|
+ block
|
|
|
+ >
|
|
|
+ 上移
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ icon={<MoveDown className="w-3 h-3" />}
|
|
|
+ onClick={() => moveField(selectedField.id, 'down')}
|
|
|
+ disabled={selectedIndex === fields.length - 1}
|
|
|
+ block
|
|
|
+ >
|
|
|
+ 下移
|
|
|
+ </Button>
|
|
|
+ <Button
|
|
|
+ type="text"
|
|
|
+ size="small"
|
|
|
+ danger
|
|
|
+ icon={<Trash2 className="w-3 h-3" />}
|
|
|
+ onClick={() => deleteField(selectedField.id)}
|
|
|
+ block
|
|
|
+ >
|
|
|
+ 删除
|
|
|
+ </Button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ) : (
|
|
|
+ <div className="p-4 text-center text-gray-400 text-sm">
|
|
|
+ 请选择一个字段进行配置
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* 预览弹窗 */}
|
|
|
+ <Modal
|
|
|
+ title="表单预览"
|
|
|
+ open={previewVisible}
|
|
|
+ onCancel={() => setPreviewVisible(false)}
|
|
|
+ footer={[
|
|
|
+ <Button key="close" onClick={() => setPreviewVisible(false)}>
|
|
|
+ 关闭
|
|
|
+ </Button>
|
|
|
+ ]}
|
|
|
+ width={800}
|
|
|
+ >
|
|
|
+ <AntdForm form={previewForm} layout="vertical" className="space-y-4">
|
|
|
+ {fields.map((field) => (
|
|
|
+ <AntdForm.Item
|
|
|
+ key={field.id}
|
|
|
+ label={field.label}
|
|
|
+ name={field.name}
|
|
|
+ required={field.required}
|
|
|
+ rules={field.required ? [{ required: true, message: `请输入${field.label}` }] : []}
|
|
|
+ >
|
|
|
+ {field.type === 'input' && <Input placeholder={field.placeholder} />}
|
|
|
+ {field.type === 'textarea' && <TextArea placeholder={field.placeholder} rows={4} />}
|
|
|
+ {field.type === 'password' && <Input.Password placeholder={field.placeholder} />}
|
|
|
+ {field.type === 'select' && (
|
|
|
+ <Select placeholder={field.placeholder}>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Select.Option key={idx} value={opt.value}>{opt.label}</Select.Option>
|
|
|
+ ))}
|
|
|
+ </Select>
|
|
|
+ )}
|
|
|
+ {field.type === 'date' && <DatePicker className="w-full" placeholder={field.placeholder} />}
|
|
|
+ {field.type === 'daterange' && <DatePicker.RangePicker className="w-full" />}
|
|
|
+ {field.type === 'number' && <InputNumber className="w-full" placeholder={field.placeholder} />}
|
|
|
+ {field.type === 'switch' && <Switch />}
|
|
|
+ {field.type === 'radio' && (
|
|
|
+ <Radio.Group>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Radio key={idx} value={opt.value}>{opt.label}</Radio>
|
|
|
+ ))}
|
|
|
+ </Radio.Group>
|
|
|
+ )}
|
|
|
+ {field.type === 'checkbox' && (
|
|
|
+ <Checkbox.Group>
|
|
|
+ {field.options?.map((opt, idx) => (
|
|
|
+ <Checkbox key={idx} value={opt.value}>{opt.label}</Checkbox>
|
|
|
+ ))}
|
|
|
+ </Checkbox.Group>
|
|
|
+ )}
|
|
|
+ </AntdForm.Item>
|
|
|
+ ))}
|
|
|
+ </AntdForm>
|
|
|
+ </Modal>
|
|
|
+
|
|
|
+ {/* 代码查看弹窗 */}
|
|
|
+ <Modal
|
|
|
+ title="表单代码"
|
|
|
+ open={codeVisible}
|
|
|
+ onCancel={() => setCodeVisible(false)}
|
|
|
+ footer={[
|
|
|
+ <Button key="close" onClick={() => setCodeVisible(false)}>
|
|
|
+ 关闭
|
|
|
+ </Button>
|
|
|
+ ]}
|
|
|
+ width={800}
|
|
|
+ >
|
|
|
+ <pre className="bg-gray-50 p-4 rounded text-xs overflow-auto max-h-96">
|
|
|
+ {JSON.stringify({ fields, formName, formRemark }, null, 2)}
|
|
|
+ </pre>
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ </DndProvider>
|
|
|
+ );
|
|
|
+}
|