From ecf3a1ba74fc1be924bc0ecab35e6f6aacfdfe51 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Fri, 24 Oct 2025 10:22:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A8=E5=8D=95=E8=AE=BE=E8=AE=A1=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/FormDesigner/Designer.tsx | 40 ++- .../src/components/FormDesigner/README.md | 290 ++++++++++++++++++ .../src/components/FormDesigner/Renderer.tsx | 52 +++- .../FormDesigner/components/DesignCanvas.tsx | 3 + .../FormDesigner/components/FieldItem.tsx | 24 +- .../FormDesigner/components/FieldRenderer.tsx | 4 +- .../FormDesigner/components/GridField.tsx | 34 +- .../FormDesigner/components/PropertyPanel.tsx | 29 +- .../src/components/FormDesigner/styles.css | 12 + 9 files changed, 473 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/FormDesigner/README.md diff --git a/frontend/src/components/FormDesigner/Designer.tsx b/frontend/src/components/FormDesigner/Designer.tsx index 7cf5c2c6..d9174611 100644 --- a/frontend/src/components/FormDesigner/Designer.tsx +++ b/frontend/src/components/FormDesigner/Designer.tsx @@ -18,7 +18,7 @@ * ``` */ -import React, { useState, useCallback, useEffect, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { Button, Space, message, Modal } from 'antd'; import { SaveOutlined, @@ -80,6 +80,42 @@ const FormDesigner: React.FC = ({ const fields = value?.fields ?? internalFields; const formConfig = value?.formConfig ?? internalFormConfig; + // 🔍 字段名冲突检测 + const fieldNames = useMemo(() => { + const names = new Map(); // name -> fieldIds[] + + const collectNames = (fieldList: FieldConfig[]) => { + fieldList.forEach(field => { + // 只收集有效的 name(非空且非布局组件) + if (field.name && field.name.trim() && field.type !== 'text' && field.type !== 'divider') { + const ids = names.get(field.name) || []; + ids.push(field.id); + names.set(field.name, ids); + } + + // 递归处理栅格布局 + if (field.type === 'grid' && field.children) { + field.children.forEach(col => collectNames(col)); + } + }); + }; + + collectNames(fields); + return names; + }, [fields]); + + // 🚨 找出所有冲突的字段 ID + const duplicateFieldIds = useMemo(() => { + const ids = new Set(); + fieldNames.forEach((fieldIds: string[]) => { + if (fieldIds.length > 1) { + // 该 name 被多个字段使用,标记所有这些字段为冲突 + fieldIds.forEach((id: string) => ids.add(id)); + } + }); + return ids; + }, [fieldNames]); + const setFields = useCallback((updater: React.SetStateAction) => { const newFields = typeof updater === 'function' ? updater(fields) : updater; @@ -748,6 +784,7 @@ const FormDesigner: React.FC = ({ hasClipboard={!!clipboard} activeId={activeId} overId={overId} + duplicateFieldIds={duplicateFieldIds} /> {/* 右侧属性面板 */} @@ -757,6 +794,7 @@ const FormDesigner: React.FC = ({ onFieldChange={handleFieldChange} onFormConfigChange={handleFormConfigChange} allFields={allFields} + fieldNames={fieldNames} /> diff --git a/frontend/src/components/FormDesigner/README.md b/frontend/src/components/FormDesigner/README.md new file mode 100644 index 00000000..43ff1eaa --- /dev/null +++ b/frontend/src/components/FormDesigner/README.md @@ -0,0 +1,290 @@ +# 表单设计器组件库 + +一个完整的表单可视化设计和渲染解决方案,包含设计时组件和运行时组件。 + +## 📦 组件 + +### 1. FormDesigner - 表单设计器(设计时) +可视化拖拽设计表单,用于表单管理页面。 + +### 2. FormRenderer - 表单渲染器(运行时) +根据 Schema 渲染表单,用于实际业务场景(工作流、数据录入等)。 + +--- + +## 🚀 快速开始 + +### 安装 + +组件已内置在项目中,直接导入使用: + +```typescript +import { FormDesigner, FormRenderer, type FormSchema } from '@/components/FormDesigner'; +``` + +--- + +## 💡 使用场景 + +### 场景 1:表单管理页面 + +在表单管理页面使用 `FormDesigner` 设计和保存表单: + +```tsx +import { FormDesigner, type FormSchema } from '@/components/FormDesigner'; + +function FormManagePage() { + const [schema, setSchema] = useState(); + + const handleSave = async (schema: FormSchema) => { + // 保存到后端 + await api.saveFormSchema(schema); + }; + + return ( + + ); +} +``` + +### 场景 2:工作流发起页面 + +在工作流中使用 `FormRenderer` 渲染表单: + +```tsx +import { FormRenderer, type FormSchema } from '@/components/FormDesigner'; + +function WorkflowStartPage() { + const [schema, setSchema] = useState(); + const [formData, setFormData] = useState({}); + + useEffect(() => { + // 从后端加载表单 Schema + api.getFormSchema(workflowId).then(setSchema); + }, [workflowId]); + + const handleSubmit = async (values: Record) => { + // 提交工作流 + await api.startWorkflow(workflowId, values); + }; + + if (!schema) return ; + + return ( + + ); +} +``` + +### 场景 3:Modal 弹窗表单 + +```tsx +import { Modal } from 'antd'; +import { FormRenderer, type FormSchema } from '@/components/FormDesigner'; + +function QuickFormModal() { + const [visible, setVisible] = useState(false); + + const formSchema: FormSchema = { + version: '1.0', + formConfig: { labelAlign: 'right', size: 'middle' }, + fields: [ + { id: '1', type: 'input', label: '姓名', name: 'name', required: true }, + { id: '2', type: 'input', label: '邮箱', name: 'email', required: true }, + ] + }; + + const handleSubmit = async (values: Record) => { + await api.submitData(values); + setVisible(false); + }; + + return ( + setVisible(false)} footer={null}> + setVisible(false)} + showCancel + /> + + ); +} +``` + +--- + +## 📖 API 文档 + +### FormDesigner Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| value | `FormSchema` | - | 受控模式:表单 Schema | +| onChange | `(schema: FormSchema) => void` | - | Schema 变化回调 | +| onSave | `(schema: FormSchema) => void` | - | 保存按钮回调 | +| readonly | `boolean` | `false` | 只读模式 | +| showToolbar | `boolean` | `true` | 是否显示工具栏 | + +### FormRenderer Props + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| schema | `FormSchema` | **必填** | 表单 Schema | +| value | `Record` | - | 受控模式:表单值 | +| onChange | `(values) => void` | - | 值变化回调 | +| onSubmit | `(values) => void` | - | 提交回调 | +| onCancel | `() => void` | - | 取消回调 | +| readonly | `boolean` | `false` | 只读模式 | +| showSubmit | `boolean` | `true` | 显示提交按钮 | +| showCancel | `boolean` | `false` | 显示取消按钮 | +| submitText | `string` | `'提交'` | 提交按钮文本 | +| cancelText | `string` | `'取消'` | 取消按钮文本 | + +### FormSchema 类型 + +```typescript +interface FormSchema { + version: string; // Schema 版本 + formConfig: FormConfig; // 表单配置 + fields: FieldConfig[]; // 字段列表 +} +``` + +--- + +## 🎯 完整示例 + +访问 `/form-designer` 路由查看完整的在线示例,包含: + +1. **设计器示例** - 演示如何设计表单 +2. **渲染器示例** - 演示如何渲染表单 +3. **工作流示例** - 演示实际业务场景 + +--- + +## 🎨 支持的字段类型 + +### 基础字段 +- 单行文本 (input) +- 多行文本 (textarea) +- 数字输入 (number) +- 下拉选择 (select) +- 单选框 (radio) +- 多选框 (checkbox) +- 开关 (switch) +- 日期选择 (date) +- 时间选择器 (datetime) +- 时间范围 (time) +- 滑块 (slider) +- 评分 (rate) +- 级联选择 (cascader) + +### 高级字段 +- 文件上传 (upload) + +### 布局字段 +- 栅格布局 (grid) - 支持拖入其他组件 +- 文字 (text) +- 分割线 (divider) + +--- + +## ✨ 核心特性 + +### 🎨 可视化设计 +- ✅ 拖拽式组件面板 +- ✅ 实时预览 +- ✅ 栅格布局容器 +- ✅ 智能拖放检测(边界识别) + +### 📝 表单功能 +- ✅ 字段验证规则(必填、正则、长度等) +- ✅ 字段联动规则(显示/隐藏、启用/禁用、赋值) +- ✅ 默认值设置 +- ✅ 数据源配置(静态、API、预定义) +- ✅ 级联选择(支持无限层级) + +### 🎯 拖拽体验 +- ✅ 复制/粘贴(Ctrl+C / Ctrl+V) +- ✅ 删除(Delete) +- ✅ 插入指示器(视觉反馈) +- ✅ 边界检测(区分插入和拖入) + +### 💾 数据管理 +- ✅ 导入/导出 JSON Schema +- ✅ 受控/非受控模式 +- ✅ 表单值双向绑定 + +--- + +## 🏗️ 架构设计 + +``` +src/components/FormDesigner/ +├── index.tsx # 统一导出入口 ⭐ +├── Designer.tsx # 设计器主组件 ⭐ +├── Renderer.tsx # 渲染器主组件 ⭐ +├── types.ts # TypeScript 类型定义 +├── config.ts # 组件配置(拖拽面板) +├── styles.css # 样式文件 +├── components/ # 子组件 +│ ├── ComponentPanel.tsx # 组件面板 +│ ├── DesignCanvas.tsx # 设计画布 +│ ├── PropertyPanel.tsx # 属性配置面板 +│ ├── FormPreview.tsx # 预览组件 +│ ├── FieldRenderer.tsx # 字段渲染器 +│ ├── GridField.tsx # 栅格布局组件 +│ └── ... # 其他子组件 +└── hooks/ # 自定义 Hooks + ├── useFieldOptions.ts # 字段选项管理 + └── useCascaderOptions.ts # 级联选项管理 +``` + +--- + +## 🔧 开发指南 + +### 添加新字段类型 + +1. 在 `types.ts` 中添加类型定义 +2. 在 `config.ts` 中注册组件元数据 +3. 在 `FieldRenderer.tsx` 中实现渲染逻辑 +4. (可选)在 `PropertyPanel.tsx` 中添加特殊配置 + +### 自定义样式 + +所有样式都在 `styles.css` 中,使用 CSS 变量可以轻松定制主题。 + +--- + +## 📚 更多资源 + +- [在线示例](/form-designer) +- [类型定义](./types.ts) +- [组件配置](./config.ts) + +--- + +## 💡 最佳实践 + +1. **Schema 存储**:将 FormSchema 存储在数据库,使用 JSON 字段类型 +2. **版本管理**:FormSchema 包含 version 字段,便于后续升级 +3. **权限控制**:使用 readonly 属性控制表单是否可编辑 +4. **数据验证**:充分利用 validationRules 进行前端验证 +5. **字段联动**:使用 linkageRules 实现复杂的业务逻辑 + +--- + +Made with ❤️ by Deploy Ease Team + diff --git a/frontend/src/components/FormDesigner/Renderer.tsx b/frontend/src/components/FormDesigner/Renderer.tsx index 9fb8a28a..072344bd 100644 --- a/frontend/src/components/FormDesigner/Renderer.tsx +++ b/frontend/src/components/FormDesigner/Renderer.tsx @@ -15,13 +15,17 @@ export interface FormRendererProps { schema: FormSchema; // 表单 Schema value?: Record; // 表单值(受控) onChange?: (values: Record) => void; // 值变化回调 - onSubmit?: (values: Record) => void; // 提交回调 + onSubmit?: (values: Record) => void | Promise; // 提交回调 onCancel?: () => void; // 取消回调 readonly?: boolean; // 只读模式 showSubmit?: boolean; // 是否显示提交按钮 showCancel?: boolean; // 是否显示取消按钮 submitText?: string; // 提交按钮文本 cancelText?: string; // 取消按钮文本 + // 🎣 生命周期钩子 + beforeSubmit?: (values: Record) => Record | false | Promise | false>; // 提交前钩子 + afterSubmit?: (response?: any) => void | Promise; // 提交成功后钩子 + onError?: (error: any) => void | Promise; // 提交失败钩子 } const FormRenderer: React.FC = ({ @@ -34,7 +38,10 @@ const FormRenderer: React.FC = ({ showSubmit = true, showCancel = false, submitText = '提交', - cancelText = '取消' + cancelText = '取消', + beforeSubmit, + afterSubmit, + onError }) => { const { fields, formConfig } = schema; const [form] = Form.useForm(); @@ -57,17 +64,52 @@ const FormRenderer: React.FC = ({ if (readonly) return; try { - const values = await form.validateFields(); + // 1️⃣ 表单验证 + let values = await form.validateFields(); + + // 2️⃣ beforeSubmit 钩子 - 数据转换或阻止提交 + if (beforeSubmit) { + const result = await beforeSubmit(values); + if (result === false) { + // 返回 false 阻止提交 + return; + } + if (result && typeof result === 'object') { + // 使用转换后的数据 + values = result; + } + } + setFormData(values); + // 3️⃣ 提交 + let response: any; if (onSubmit) { - onSubmit(values); + response = await onSubmit(values); } else { console.log('表单提交数据:', values); message.success('表单提交成功!'); } + + // 4️⃣ afterSubmit 钩子 - 提交成功后处理 + if (afterSubmit) { + try { + await afterSubmit(response); + } catch (err) { + console.error('afterSubmit error:', err); + } + } } catch (error) { - message.error('请填写必填项'); + // 5️⃣ onError 钩子 - 错误处理 + if (onError) { + try { + await onError(error); + } catch (err) { + console.error('onError hook error:', err); + } + } else { + message.error('请填写必填项'); + } } }; diff --git a/frontend/src/components/FormDesigner/components/DesignCanvas.tsx b/frontend/src/components/FormDesigner/components/DesignCanvas.tsx index 3d975d15..3450614f 100644 --- a/frontend/src/components/FormDesigner/components/DesignCanvas.tsx +++ b/frontend/src/components/FormDesigner/components/DesignCanvas.tsx @@ -26,6 +26,7 @@ interface DesignCanvasProps { hasClipboard?: boolean; activeId?: string | null; // 正在拖拽的元素ID overId?: string | null; // 拖拽悬停的目标ID + duplicateFieldIds?: Set; // 冲突的字段ID集合 } const DesignCanvas: React.FC = ({ @@ -40,6 +41,7 @@ const DesignCanvas: React.FC = ({ hasClipboard = false, activeId, overId, + duplicateFieldIds = new Set(), }) => { const { setNodeRef } = useDroppable({ id: 'canvas', @@ -117,6 +119,7 @@ const DesignCanvas: React.FC = ({ onDeleteField={onDeleteField} labelAlign={labelAlign} hasClipboard={hasClipboard} + hasDuplicateName={duplicateFieldIds.has(field.id)} /> ))} diff --git a/frontend/src/components/FormDesigner/components/FieldItem.tsx b/frontend/src/components/FormDesigner/components/FieldItem.tsx index a5969722..988de188 100644 --- a/frontend/src/components/FormDesigner/components/FieldItem.tsx +++ b/frontend/src/components/FormDesigner/components/FieldItem.tsx @@ -6,8 +6,8 @@ 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 { Button, Space, Tooltip } from 'antd'; +import { HolderOutlined, DeleteOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; import type { FieldConfig } from '../types'; import FieldRenderer from './FieldRenderer'; import '../styles.css'; @@ -25,6 +25,7 @@ interface FieldItemProps { onDeleteField?: (fieldId: string) => void; labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式 hasClipboard?: boolean; + hasDuplicateName?: boolean; // 是否字段名冲突 } const FieldItem: React.FC = ({ @@ -40,6 +41,7 @@ const FieldItem: React.FC = ({ onDeleteField, labelAlign = 'right', hasClipboard = false, + hasDuplicateName = false, }) => { const { attributes, @@ -65,13 +67,29 @@ const FieldItem: React.FC = ({
+ {/* 冲突警告图标 */} + {hasDuplicateName && ( + + + + )} +
void; labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式 hasClipboard?: boolean; + duplicateFieldIds?: Set; // 冲突的字段ID集合 } const FieldRenderer: React.FC = ({ @@ -59,6 +60,7 @@ const FieldRenderer: React.FC = ({ onDeleteField, labelAlign = 'right', hasClipboard = false, + duplicateFieldIds = new Set(), }) => { // 获取字段选项(支持静态和动态数据源) const options = useFieldOptions(field); @@ -100,6 +102,7 @@ const FieldRenderer: React.FC = ({ onDeleteField={onDeleteField} labelAlign={labelAlign} hasClipboard={hasClipboard} + duplicateFieldIds={duplicateFieldIds} /> ); } @@ -117,7 +120,6 @@ const FieldRenderer: React.FC = ({ ); case 'textarea': - console.log('📝 [FieldRenderer] 渲染 textarea,rows:', field.rows, 'field:', field.name); return (