Browse Source

Formily表单生成插件集成

wyn 5 months ago
parent
commit
4717f7e40a
5 changed files with 973 additions and 73 deletions
  1. 134 5
      package-lock.json
  2. 2 0
      package.json
  3. 814 0
      src/components/FormDesigner.tsx
  4. 14 68
      src/components/FormManagement.tsx
  5. 9 0
      src/routes/index.tsx

+ 134 - 5
package-lock.json

@@ -47,6 +47,8 @@
                         "next-themes": "^0.4.6",
                         "react": "^18.3.1",
                         "react-day-picker": "^8.10.1",
+                        "react-dnd": "^16.0.1",
+                        "react-dnd-html5-backend": "^16.0.1",
                         "react-dom": "^18.3.1",
                         "react-hook-form": "^7.55.0",
                         "react-i18next": "^16.3.5",
@@ -2796,6 +2798,21 @@
                         "react-dom": ">=18.0.0"
                   }
             },
+            "node_modules/@react-dnd/asap": {
+                  "version": "5.0.2",
+                  "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+                  "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
+            },
+            "node_modules/@react-dnd/invariant": {
+                  "version": "4.0.2",
+                  "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+                  "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
+            },
+            "node_modules/@react-dnd/shallowequal": {
+                  "version": "4.0.2",
+                  "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+                  "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
+            },
             "node_modules/@reactflow/background": {
                   "version": "11.3.14",
                   "resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
@@ -3640,17 +3657,25 @@
                   "version": "20.19.25",
                   "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz",
                   "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==",
-                  "dev": true,
+                  "devOptional": true,
                   "dependencies": {
                         "undici-types": "~6.21.0"
                   }
             },
+            "node_modules/@types/prop-types": {
+                  "version": "15.7.15",
+                  "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
+                  "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
+                  "devOptional": true
+            },
             "node_modules/@types/react": {
-                  "version": "19.2.7",
-                  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
-                  "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
+                  "version": "16.14.68",
+                  "resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.68.tgz",
+                  "integrity": "sha512-GEe60JEJg7wIvnUzXBX/A++ieyum98WXF/q2oFr1RVar8OK8JxU/uEYBXgv7jF87SoaDdxtAq3KUaJFlu02ziw==",
                   "devOptional": true,
                   "dependencies": {
+                        "@types/prop-types": "*",
+                        "@types/scheduler": "^0.16",
                         "csstype": "^3.2.2"
                   }
             },
@@ -3675,6 +3700,12 @@
                         "@types/react-router": "*"
                   }
             },
+            "node_modules/@types/scheduler": {
+                  "version": "0.16.8",
+                  "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz",
+                  "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==",
+                  "devOptional": true
+            },
             "node_modules/@vitejs/plugin-react-swc": {
                   "version": "3.11.0",
                   "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz",
@@ -4070,6 +4101,16 @@
                   "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz",
                   "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="
             },
+            "node_modules/dnd-core": {
+                  "version": "16.0.1",
+                  "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+                  "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
+                  "dependencies": {
+                        "@react-dnd/asap": "^5.0.1",
+                        "@react-dnd/invariant": "^4.0.1",
+                        "redux": "^4.2.0"
+                  }
+            },
             "node_modules/dom-helpers": {
                   "version": "5.2.1",
                   "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
@@ -4204,6 +4245,11 @@
                   "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
                   "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
             },
+            "node_modules/fast-deep-equal": {
+                  "version": "3.1.3",
+                  "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+                  "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+            },
             "node_modules/fast-equals": {
                   "version": "5.3.3",
                   "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz",
@@ -4375,6 +4421,19 @@
                         "node": ">= 0.4"
                   }
             },
+            "node_modules/hoist-non-react-statics": {
+                  "version": "3.3.2",
+                  "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+                  "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+                  "dependencies": {
+                        "react-is": "^16.7.0"
+                  }
+            },
+            "node_modules/hoist-non-react-statics/node_modules/react-is": {
+                  "version": "16.13.1",
+                  "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+                  "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+            },
             "node_modules/html-parse-stringify": {
                   "version": "3.0.1",
                   "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
@@ -4421,6 +4480,17 @@
                         "@babel/runtime": "^7.23.2"
                   }
             },
+            "node_modules/immer": {
+                  "version": "11.0.1",
+                  "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
+                  "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
+                  "optional": true,
+                  "peer": true,
+                  "funding": {
+                        "type": "opencollective",
+                        "url": "https://opencollective.com/immer"
+                  }
+            },
             "node_modules/input-otp": {
                   "version": "1.4.2",
                   "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz",
@@ -4693,6 +4763,43 @@
                         "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
                   }
             },
+            "node_modules/react-dnd": {
+                  "version": "16.0.1",
+                  "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+                  "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
+                  "dependencies": {
+                        "@react-dnd/invariant": "^4.0.1",
+                        "@react-dnd/shallowequal": "^4.0.1",
+                        "dnd-core": "^16.0.1",
+                        "fast-deep-equal": "^3.1.3",
+                        "hoist-non-react-statics": "^3.3.2"
+                  },
+                  "peerDependencies": {
+                        "@types/hoist-non-react-statics": ">= 3.3.1",
+                        "@types/node": ">= 12",
+                        "@types/react": ">= 16",
+                        "react": ">= 16.14"
+                  },
+                  "peerDependenciesMeta": {
+                        "@types/hoist-non-react-statics": {
+                              "optional": true
+                        },
+                        "@types/node": {
+                              "optional": true
+                        },
+                        "@types/react": {
+                              "optional": true
+                        }
+                  }
+            },
+            "node_modules/react-dnd-html5-backend": {
+                  "version": "16.0.1",
+                  "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+                  "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
+                  "dependencies": {
+                        "dnd-core": "^16.0.1"
+                  }
+            },
             "node_modules/react-dom": {
                   "version": "18.3.1",
                   "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
@@ -4938,6 +5045,14 @@
                         "decimal.js-light": "^2.4.1"
                   }
             },
+            "node_modules/redux": {
+                  "version": "4.2.1",
+                  "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+                  "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+                  "dependencies": {
+                        "@babel/runtime": "^7.9.2"
+                  }
+            },
             "node_modules/resize-observer-polyfill": {
                   "version": "1.5.1",
                   "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -5076,11 +5191,25 @@
                   "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
                   "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
             },
+            "node_modules/typescript": {
+                  "version": "5.9.3",
+                  "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+                  "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+                  "optional": true,
+                  "peer": true,
+                  "bin": {
+                        "tsc": "bin/tsc",
+                        "tsserver": "bin/tsserver"
+                  },
+                  "engines": {
+                        "node": ">=14.17"
+                  }
+            },
             "node_modules/undici-types": {
                   "version": "6.21.0",
                   "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
                   "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-                  "dev": true
+                  "devOptional": true
             },
             "node_modules/use-callback-ref": {
                   "version": "1.3.3",

+ 2 - 0
package.json

@@ -42,6 +42,8 @@
             "next-themes": "^0.4.6",
             "react": "^18.3.1",
             "react-day-picker": "^8.10.1",
+            "react-dnd": "^16.0.1",
+            "react-dnd-html5-backend": "^16.0.1",
             "react-dom": "^18.3.1",
             "react-hook-form": "^7.55.0",
             "react-i18next": "^16.3.5",

+ 814 - 0
src/components/FormDesigner.tsx

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

+ 14 - 68
src/components/FormManagement.tsx

@@ -1,7 +1,7 @@
 import React, { useState, useEffect } from 'react';
 import { useNavigate, useLocation } from 'react-router-dom';
-import { Search, Plus, RefreshCw, Edit2, Trash2, Copy, Eye, X } from 'lucide-react';
-import { Button, Input, Space, Table as AntdTable, Modal, message } from 'antd';
+import { Plus, Edit2, Trash2, Copy, Eye, X } from 'lucide-react';
+import { Button, Space, Table as AntdTable, Modal, message } from 'antd';
 import type { ColumnsType } from 'antd/es/table';
 import { toast } from 'sonner';
 import * as FormApi from '../api/bpm/form';
@@ -23,8 +23,7 @@ export default function FormManagement() {
   const [total, setTotal] = useState(0);
   const [queryParams, setQueryParams] = useState<FormApi.FormPageParams>({
     pageNo: 1,
-    pageSize: 10,
-    name: undefined
+    pageSize: 10
   });
   
   // 详情弹窗
@@ -58,27 +57,7 @@ export default function FormManagement() {
   useEffect(() => {
     getList();
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [queryParams.pageNo, queryParams.pageSize, queryParams.name]);
-
-  /** 搜索按钮操作 */
-  const handleQuery = () => {
-    const newParams = { ...queryParams, pageNo: 1 };
-    setQueryParams(newParams);
-    // 立即触发数据加载
-    getList(newParams);
-  };
-
-  /** 重置按钮操作 */
-  const resetQuery = () => {
-    const resetParams = {
-      pageNo: 1,
-      pageSize: 10,
-      name: undefined
-    };
-    setQueryParams(resetParams);
-    // 立即触发数据加载
-    getList(resetParams);
-  };
+  }, [queryParams.pageNo, queryParams.pageSize]);
 
   /** 添加/修改操作 */
   const openForm = (type: string, id?: number) => {
@@ -216,51 +195,18 @@ export default function FormManagement() {
 
   return (
     <div className="space-y-6">
-      {/* 搜索栏和表格容器 */}
+      {/* 操作栏和表格容器 */}
       <div className="bg-white rounded-2xl border border-gray-200/50 shadow-sm overflow-hidden">
-        {/* 搜索栏 */}
+        {/* 操作栏 */}
         <div className="p-4 lg:p-5 border-b border-gray-200/50">
-          <div className="flex items-center justify-between gap-3 lg:gap-4 flex-wrap min-w-0">
-            {/* 搜索输入框 */}
-            <div className="flex items-center gap-2 lg:gap-3 flex-wrap flex-1 min-w-0">
-              <div className="flex items-center gap-2 lg:gap-3 flex-shrink-0">
-                <label className="text-sm font-medium text-gray-700 whitespace-nowrap">表单名:</label>
-                <Input
-                  value={queryParams.name || ''}
-                  onChange={(e) => setQueryParams({ ...queryParams, name: e.target.value || undefined })}
-                  onPressEnter={handleQuery}
-                  placeholder="请输入表单名"
-                  className="min-w-[150px] max-w-[200px]"
-                  allowClear
-                />
-              </div>
-            </div>
-
-            {/* 操作按钮组 */}
-            <Space className="flex-shrink-0">
-              <Button
-                type="primary"
-                icon={<Search className="w-4 h-4" />}
-                onClick={handleQuery}
-              >
-                搜索
-              </Button>
-              
-              <Button
-                icon={<RefreshCw className="w-4 h-4" />}
-                onClick={resetQuery}
-              >
-                重置
-              </Button>
-              
-              <Button
-                type="primary"
-                icon={<Plus className="w-4 h-4" />}
-                onClick={() => openForm('create')}
-              >
-                新增
-              </Button>
-            </Space>
+          <div className="flex items-center justify-end">
+            <Button
+              type="primary"
+              icon={<Plus className="w-4 h-4" />}
+              onClick={() => openForm('create')}
+            >
+              新增
+            </Button>
           </div>
         </div>
 

+ 9 - 0
src/routes/index.tsx

@@ -4,6 +4,7 @@ import Dashboard from '../Dashboard';
 import Login from '../views/Login';
 import ProtectedRoute from '../components/ProtectedRoute';
 import ProcessDesigner from '../components/ProcessDesigner';
+import FormDesigner from '../components/FormDesigner';
 
 // 路由配置
 export const router = createBrowserRouter([
@@ -53,6 +54,14 @@ export const router = createBrowserRouter([
       </ProtectedRoute>
     ),
   },
+  {
+    path: '/bpm/form/editor',
+    element: (
+      <ProtectedRoute>
+        <FormDesigner />
+      </ProtectedRoute>
+    ),
+  },
   {
     path: '/clientSystem/*',
     element: (