表单设计器
This commit is contained in:
parent
1058487821
commit
ecf3a1ba74
@ -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<FormDesignerProps> = ({
|
||||
const fields = value?.fields ?? internalFields;
|
||||
const formConfig = value?.formConfig ?? internalFormConfig;
|
||||
|
||||
// 🔍 字段名冲突检测
|
||||
const fieldNames = useMemo(() => {
|
||||
const names = new Map<string, string[]>(); // 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<string>();
|
||||
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<FieldConfig[]>) => {
|
||||
const newFields = typeof updater === 'function' ? updater(fields) : updater;
|
||||
|
||||
@ -748,6 +784,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
hasClipboard={!!clipboard}
|
||||
activeId={activeId}
|
||||
overId={overId}
|
||||
duplicateFieldIds={duplicateFieldIds}
|
||||
/>
|
||||
|
||||
{/* 右侧属性面板 */}
|
||||
@ -757,6 +794,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
|
||||
onFieldChange={handleFieldChange}
|
||||
onFormConfigChange={handleFormConfigChange}
|
||||
allFields={allFields}
|
||||
fieldNames={fieldNames}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
290
frontend/src/components/FormDesigner/README.md
Normal file
290
frontend/src/components/FormDesigner/README.md
Normal file
@ -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<FormSchema>();
|
||||
|
||||
const handleSave = async (schema: FormSchema) => {
|
||||
// 保存到后端
|
||||
await api.saveFormSchema(schema);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormDesigner
|
||||
value={schema}
|
||||
onChange={setSchema}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 2:工作流发起页面
|
||||
|
||||
在工作流中使用 `FormRenderer` 渲染表单:
|
||||
|
||||
```tsx
|
||||
import { FormRenderer, type FormSchema } from '@/components/FormDesigner';
|
||||
|
||||
function WorkflowStartPage() {
|
||||
const [schema, setSchema] = useState<FormSchema>();
|
||||
const [formData, setFormData] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
// 从后端加载表单 Schema
|
||||
api.getFormSchema(workflowId).then(setSchema);
|
||||
}, [workflowId]);
|
||||
|
||||
const handleSubmit = async (values: Record<string, any>) => {
|
||||
// 提交工作流
|
||||
await api.startWorkflow(workflowId, values);
|
||||
};
|
||||
|
||||
if (!schema) return <Loading />;
|
||||
|
||||
return (
|
||||
<FormRenderer
|
||||
schema={schema}
|
||||
value={formData}
|
||||
onChange={setFormData}
|
||||
onSubmit={handleSubmit}
|
||||
submitText="发起工作流"
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 场景 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<string, any>) => {
|
||||
await api.submitData(values);
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={visible} onCancel={() => setVisible(false)} footer={null}>
|
||||
<FormRenderer
|
||||
schema={formSchema}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => setVisible(false)}
|
||||
showCancel
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 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<string, any>` | - | 受控模式:表单值 |
|
||||
| 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
|
||||
|
||||
@ -15,13 +15,17 @@ export interface FormRendererProps {
|
||||
schema: FormSchema; // 表单 Schema
|
||||
value?: Record<string, any>; // 表单值(受控)
|
||||
onChange?: (values: Record<string, any>) => void; // 值变化回调
|
||||
onSubmit?: (values: Record<string, any>) => void; // 提交回调
|
||||
onSubmit?: (values: Record<string, any>) => void | Promise<any>; // 提交回调
|
||||
onCancel?: () => void; // 取消回调
|
||||
readonly?: boolean; // 只读模式
|
||||
showSubmit?: boolean; // 是否显示提交按钮
|
||||
showCancel?: boolean; // 是否显示取消按钮
|
||||
submitText?: string; // 提交按钮文本
|
||||
cancelText?: string; // 取消按钮文本
|
||||
// 🎣 生命周期钩子
|
||||
beforeSubmit?: (values: Record<string, any>) => Record<string, any> | false | Promise<Record<string, any> | false>; // 提交前钩子
|
||||
afterSubmit?: (response?: any) => void | Promise<void>; // 提交成功后钩子
|
||||
onError?: (error: any) => void | Promise<void>; // 提交失败钩子
|
||||
}
|
||||
|
||||
const FormRenderer: React.FC<FormRendererProps> = ({
|
||||
@ -34,7 +38,10 @@ const FormRenderer: React.FC<FormRendererProps> = ({
|
||||
showSubmit = true,
|
||||
showCancel = false,
|
||||
submitText = '提交',
|
||||
cancelText = '取消'
|
||||
cancelText = '取消',
|
||||
beforeSubmit,
|
||||
afterSubmit,
|
||||
onError
|
||||
}) => {
|
||||
const { fields, formConfig } = schema;
|
||||
const [form] = Form.useForm();
|
||||
@ -57,18 +64,53 @@ const FormRenderer: React.FC<FormRendererProps> = ({
|
||||
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) {
|
||||
// 5️⃣ onError 钩子 - 错误处理
|
||||
if (onError) {
|
||||
try {
|
||||
await onError(error);
|
||||
} catch (err) {
|
||||
console.error('onError hook error:', err);
|
||||
}
|
||||
} else {
|
||||
message.error('请填写必填项');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
|
||||
@ -26,6 +26,7 @@ interface DesignCanvasProps {
|
||||
hasClipboard?: boolean;
|
||||
activeId?: string | null; // 正在拖拽的元素ID
|
||||
overId?: string | null; // 拖拽悬停的目标ID
|
||||
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
|
||||
}
|
||||
|
||||
const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
@ -40,6 +41,7 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
hasClipboard = false,
|
||||
activeId,
|
||||
overId,
|
||||
duplicateFieldIds = new Set(),
|
||||
}) => {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: 'canvas',
|
||||
@ -117,6 +119,7 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
hasDuplicateName={duplicateFieldIds.has(field.id)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
@ -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<FieldItemProps> = ({
|
||||
@ -40,6 +41,7 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
hasDuplicateName = false,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
@ -65,13 +67,29 @@ const FieldItem: React.FC<FieldItemProps> = ({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`form-designer-field-item ${isSelected ? 'selected' : ''}`}
|
||||
className={`form-designer-field-item ${isSelected ? 'selected' : ''} ${hasDuplicateName ? 'duplicate-name' : ''}`}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="form-designer-field-drag-handle" {...attributes} {...listeners}>
|
||||
<HolderOutlined />
|
||||
</div>
|
||||
|
||||
{/* 冲突警告图标 */}
|
||||
{hasDuplicateName && (
|
||||
<Tooltip title="字段名称与其他字段重复">
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 35,
|
||||
color: '#ff4d4f',
|
||||
fontSize: 16,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="form-designer-field-content">
|
||||
<FieldRenderer
|
||||
field={field}
|
||||
|
||||
@ -45,6 +45,7 @@ interface FieldRendererProps {
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||
hasClipboard?: boolean;
|
||||
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
|
||||
}
|
||||
|
||||
const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
@ -59,6 +60,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
duplicateFieldIds = new Set(),
|
||||
}) => {
|
||||
// 获取字段选项(支持静态和动态数据源)
|
||||
const options = useFieldOptions(field);
|
||||
@ -100,6 +102,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
onDeleteField={onDeleteField}
|
||||
labelAlign={labelAlign}
|
||||
hasClipboard={hasClipboard}
|
||||
duplicateFieldIds={duplicateFieldIds}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -117,7 +120,6 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
console.log('📝 [FieldRenderer] 渲染 textarea,rows:', field.rows, 'field:', field.name);
|
||||
return (
|
||||
<TextArea
|
||||
rows={field.rows || 4}
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { Row, Col, Button, Space } from 'antd';
|
||||
import { DeleteOutlined, CopyOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Row, Col, Button, Space, Tooltip } from 'antd';
|
||||
import { DeleteOutlined, CopyOutlined, PlusOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import type { FieldConfig } from '../types';
|
||||
import FieldRenderer from './FieldRenderer';
|
||||
|
||||
@ -20,6 +20,7 @@ interface GridFieldProps {
|
||||
onDeleteField?: (fieldId: string) => void;
|
||||
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
|
||||
hasClipboard?: boolean;
|
||||
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
|
||||
}
|
||||
|
||||
const GridField: React.FC<GridFieldProps> = ({
|
||||
@ -32,6 +33,7 @@ const GridField: React.FC<GridFieldProps> = ({
|
||||
onDeleteField,
|
||||
labelAlign = 'right',
|
||||
hasClipboard = false,
|
||||
duplicateFieldIds = new Set(),
|
||||
}) => {
|
||||
const columns = field.columns || 2;
|
||||
const children = field.children || Array(columns).fill([]);
|
||||
@ -67,6 +69,7 @@ const GridField: React.FC<GridFieldProps> = ({
|
||||
gridId={field.id}
|
||||
colIndex={colIndex}
|
||||
hasClipboard={hasClipboard}
|
||||
duplicateFieldIds={duplicateFieldIds}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -90,6 +93,7 @@ interface GridColumnProps {
|
||||
gridId?: string;
|
||||
colIndex?: number;
|
||||
hasClipboard?: boolean;
|
||||
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
|
||||
}
|
||||
|
||||
const GridColumn: React.FC<GridColumnProps> = ({
|
||||
@ -106,6 +110,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
gridId,
|
||||
colIndex,
|
||||
hasClipboard = false,
|
||||
duplicateFieldIds = new Set(),
|
||||
}) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: dropId,
|
||||
@ -127,6 +132,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
field={childField}
|
||||
isPreview={isPreview}
|
||||
labelAlign={labelAlign}
|
||||
duplicateFieldIds={duplicateFieldIds}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -210,9 +216,13 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
padding: 8,
|
||||
border: selectedFieldId === childField.id ? '2px solid #1890ff' : '1px solid #e8e8e8',
|
||||
border: duplicateFieldIds.has(childField.id)
|
||||
? '2px solid #ff4d4f' // 冲突:红色边框
|
||||
: selectedFieldId === childField.id
|
||||
? '2px solid #1890ff' // 选中:蓝色边框
|
||||
: '1px solid #e8e8e8', // 正常:灰色边框
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
background: duplicateFieldIds.has(childField.id) ? '#fff1f0' : '#fff', // 冲突:浅红色背景
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
@ -221,6 +231,22 @@ const GridColumn: React.FC<GridColumnProps> = ({
|
||||
onSelectField?.(childField.id);
|
||||
}}
|
||||
>
|
||||
{/* 冲突警告图标 */}
|
||||
{duplicateFieldIds.has(childField.id) && (
|
||||
<Tooltip title="字段名称与其他字段重复">
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 4,
|
||||
color: '#ff4d4f',
|
||||
fontSize: 16,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<FieldRenderer field={childField} isPreview={isPreview} labelAlign={labelAlign} />
|
||||
|
||||
{/* 字段操作按钮 */}
|
||||
|
||||
@ -32,6 +32,7 @@ interface PropertyPanelProps {
|
||||
onFieldChange: (field: FieldConfig) => void;
|
||||
onFormConfigChange: (newConfig: Partial<FormConfig>) => void;
|
||||
allFields?: FieldConfig[]; // 所有字段,用于联动规则配置
|
||||
fieldNames?: Map<string, string[]>; // 字段名映射,用于冲突检测
|
||||
}
|
||||
|
||||
const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
@ -40,6 +41,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
onFieldChange,
|
||||
onFormConfigChange,
|
||||
allFields = [],
|
||||
fieldNames,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
@ -195,7 +197,32 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
|
||||
<Input placeholder="请输入字段标签" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="字段名称" name="name" rules={[{ required: true }]}>
|
||||
<Form.Item
|
||||
label="字段名称"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: '请输入字段名称' },
|
||||
{
|
||||
validator: (_, value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// 检查是否与其他字段冲突
|
||||
if (fieldNames) {
|
||||
const existingFieldIds = fieldNames.get(value) || [];
|
||||
const hasDuplicate = existingFieldIds.some(id => id !== selectedField.id);
|
||||
|
||||
if (hasDuplicate) {
|
||||
return Promise.reject(new Error('字段名称与其他字段重复,请修改'));
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Input placeholder="请输入字段名称(英文)" />
|
||||
</Form.Item>
|
||||
</>
|
||||
|
||||
@ -157,6 +157,18 @@
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
/* 字段名冲突样式 */
|
||||
.form-designer-field-item.duplicate-name {
|
||||
border-color: #ff4d4f !important;
|
||||
background: #fff1f0 !important;
|
||||
box-shadow: 0 2px 8px rgba(255, 77, 79, 0.15) !important;
|
||||
}
|
||||
|
||||
.form-designer-field-item.duplicate-name:hover {
|
||||
border-color: #ff4d4f !important;
|
||||
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.25) !important;
|
||||
}
|
||||
|
||||
.form-designer-field-drag-handle {
|
||||
color: #8c8c8c;
|
||||
font-size: 16px;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user