表单设计器

This commit is contained in:
dengqichen 2025-10-24 10:22:34 +08:00
parent 1058487821
commit ecf3a1ba74
9 changed files with 473 additions and 15 deletions

View File

@ -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 { Button, Space, message, Modal } from 'antd';
import { import {
SaveOutlined, SaveOutlined,
@ -80,6 +80,42 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
const fields = value?.fields ?? internalFields; const fields = value?.fields ?? internalFields;
const formConfig = value?.formConfig ?? internalFormConfig; 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 setFields = useCallback((updater: React.SetStateAction<FieldConfig[]>) => {
const newFields = typeof updater === 'function' ? updater(fields) : updater; const newFields = typeof updater === 'function' ? updater(fields) : updater;
@ -748,6 +784,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
hasClipboard={!!clipboard} hasClipboard={!!clipboard}
activeId={activeId} activeId={activeId}
overId={overId} overId={overId}
duplicateFieldIds={duplicateFieldIds}
/> />
{/* 右侧属性面板 */} {/* 右侧属性面板 */}
@ -757,6 +794,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
onFieldChange={handleFieldChange} onFieldChange={handleFieldChange}
onFormConfigChange={handleFormConfigChange} onFormConfigChange={handleFormConfigChange}
allFields={allFields} allFields={allFields}
fieldNames={fieldNames}
/> />
</div> </div>

View 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="发起工作流"
/>
);
}
```
### 场景 3Modal 弹窗表单
```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

View File

@ -15,13 +15,17 @@ export interface FormRendererProps {
schema: FormSchema; // 表单 Schema schema: FormSchema; // 表单 Schema
value?: Record<string, any>; // 表单值(受控) value?: Record<string, any>; // 表单值(受控)
onChange?: (values: Record<string, any>) => void; // 值变化回调 onChange?: (values: Record<string, any>) => void; // 值变化回调
onSubmit?: (values: Record<string, any>) => void; // 提交回调 onSubmit?: (values: Record<string, any>) => void | Promise<any>; // 提交回调
onCancel?: () => void; // 取消回调 onCancel?: () => void; // 取消回调
readonly?: boolean; // 只读模式 readonly?: boolean; // 只读模式
showSubmit?: boolean; // 是否显示提交按钮 showSubmit?: boolean; // 是否显示提交按钮
showCancel?: boolean; // 是否显示取消按钮 showCancel?: boolean; // 是否显示取消按钮
submitText?: string; // 提交按钮文本 submitText?: string; // 提交按钮文本
cancelText?: 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> = ({ const FormRenderer: React.FC<FormRendererProps> = ({
@ -34,7 +38,10 @@ const FormRenderer: React.FC<FormRendererProps> = ({
showSubmit = true, showSubmit = true,
showCancel = false, showCancel = false,
submitText = '提交', submitText = '提交',
cancelText = '取消' cancelText = '取消',
beforeSubmit,
afterSubmit,
onError
}) => { }) => {
const { fields, formConfig } = schema; const { fields, formConfig } = schema;
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -57,17 +64,52 @@ const FormRenderer: React.FC<FormRendererProps> = ({
if (readonly) return; if (readonly) return;
try { 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); setFormData(values);
// 3⃣ 提交
let response: any;
if (onSubmit) { if (onSubmit) {
onSubmit(values); response = await onSubmit(values);
} else { } else {
console.log('表单提交数据:', values); console.log('表单提交数据:', values);
message.success('表单提交成功!'); message.success('表单提交成功!');
} }
// 4⃣ afterSubmit 钩子 - 提交成功后处理
if (afterSubmit) {
try {
await afterSubmit(response);
} catch (err) {
console.error('afterSubmit error:', err);
}
}
} catch (error) { } catch (error) {
message.error('请填写必填项'); // 5⃣ onError 钩子 - 错误处理
if (onError) {
try {
await onError(error);
} catch (err) {
console.error('onError hook error:', err);
}
} else {
message.error('请填写必填项');
}
} }
}; };

View File

@ -26,6 +26,7 @@ interface DesignCanvasProps {
hasClipboard?: boolean; hasClipboard?: boolean;
activeId?: string | null; // 正在拖拽的元素ID activeId?: string | null; // 正在拖拽的元素ID
overId?: string | null; // 拖拽悬停的目标ID overId?: string | null; // 拖拽悬停的目标ID
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
} }
const DesignCanvas: React.FC<DesignCanvasProps> = ({ const DesignCanvas: React.FC<DesignCanvasProps> = ({
@ -40,6 +41,7 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
hasClipboard = false, hasClipboard = false,
activeId, activeId,
overId, overId,
duplicateFieldIds = new Set(),
}) => { }) => {
const { setNodeRef } = useDroppable({ const { setNodeRef } = useDroppable({
id: 'canvas', id: 'canvas',
@ -117,6 +119,7 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
onDeleteField={onDeleteField} onDeleteField={onDeleteField}
labelAlign={labelAlign} labelAlign={labelAlign}
hasClipboard={hasClipboard} hasClipboard={hasClipboard}
hasDuplicateName={duplicateFieldIds.has(field.id)}
/> />
</React.Fragment> </React.Fragment>
))} ))}

View File

@ -6,8 +6,8 @@
import React from 'react'; import React from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { Button, Space } from 'antd'; import { Button, Space, Tooltip } from 'antd';
import { HolderOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'; import { HolderOutlined, DeleteOutlined, CopyOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import type { FieldConfig } from '../types'; import type { FieldConfig } from '../types';
import FieldRenderer from './FieldRenderer'; import FieldRenderer from './FieldRenderer';
import '../styles.css'; import '../styles.css';
@ -25,6 +25,7 @@ interface FieldItemProps {
onDeleteField?: (fieldId: string) => void; onDeleteField?: (fieldId: string) => void;
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式 labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
hasClipboard?: boolean; hasClipboard?: boolean;
hasDuplicateName?: boolean; // 是否字段名冲突
} }
const FieldItem: React.FC<FieldItemProps> = ({ const FieldItem: React.FC<FieldItemProps> = ({
@ -40,6 +41,7 @@ const FieldItem: React.FC<FieldItemProps> = ({
onDeleteField, onDeleteField,
labelAlign = 'right', labelAlign = 'right',
hasClipboard = false, hasClipboard = false,
hasDuplicateName = false,
}) => { }) => {
const { const {
attributes, attributes,
@ -65,13 +67,29 @@ const FieldItem: React.FC<FieldItemProps> = ({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`form-designer-field-item ${isSelected ? 'selected' : ''}`} className={`form-designer-field-item ${isSelected ? 'selected' : ''} ${hasDuplicateName ? 'duplicate-name' : ''}`}
onClick={onSelect} onClick={onSelect}
> >
<div className="form-designer-field-drag-handle" {...attributes} {...listeners}> <div className="form-designer-field-drag-handle" {...attributes} {...listeners}>
<HolderOutlined /> <HolderOutlined />
</div> </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"> <div className="form-designer-field-content">
<FieldRenderer <FieldRenderer
field={field} field={field}

View File

@ -45,6 +45,7 @@ interface FieldRendererProps {
onDeleteField?: (fieldId: string) => void; onDeleteField?: (fieldId: string) => void;
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式 labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
hasClipboard?: boolean; hasClipboard?: boolean;
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
} }
const FieldRenderer: React.FC<FieldRendererProps> = ({ const FieldRenderer: React.FC<FieldRendererProps> = ({
@ -59,6 +60,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
onDeleteField, onDeleteField,
labelAlign = 'right', labelAlign = 'right',
hasClipboard = false, hasClipboard = false,
duplicateFieldIds = new Set(),
}) => { }) => {
// 获取字段选项(支持静态和动态数据源) // 获取字段选项(支持静态和动态数据源)
const options = useFieldOptions(field); const options = useFieldOptions(field);
@ -100,6 +102,7 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
onDeleteField={onDeleteField} onDeleteField={onDeleteField}
labelAlign={labelAlign} labelAlign={labelAlign}
hasClipboard={hasClipboard} hasClipboard={hasClipboard}
duplicateFieldIds={duplicateFieldIds}
/> />
); );
} }
@ -117,7 +120,6 @@ const FieldRenderer: React.FC<FieldRendererProps> = ({
); );
case 'textarea': case 'textarea':
console.log('📝 [FieldRenderer] 渲染 textarearows:', field.rows, 'field:', field.name);
return ( return (
<TextArea <TextArea
rows={field.rows || 4} rows={field.rows || 4}

View File

@ -5,8 +5,8 @@
import React from 'react'; import React from 'react';
import { useDroppable } from '@dnd-kit/core'; import { useDroppable } from '@dnd-kit/core';
import { Row, Col, Button, Space } from 'antd'; import { Row, Col, Button, Space, Tooltip } from 'antd';
import { DeleteOutlined, CopyOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, CopyOutlined, PlusOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import type { FieldConfig } from '../types'; import type { FieldConfig } from '../types';
import FieldRenderer from './FieldRenderer'; import FieldRenderer from './FieldRenderer';
@ -20,6 +20,7 @@ interface GridFieldProps {
onDeleteField?: (fieldId: string) => void; onDeleteField?: (fieldId: string) => void;
labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式 labelAlign?: 'left' | 'right' | 'top'; // 标签对齐方式
hasClipboard?: boolean; hasClipboard?: boolean;
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
} }
const GridField: React.FC<GridFieldProps> = ({ const GridField: React.FC<GridFieldProps> = ({
@ -32,6 +33,7 @@ const GridField: React.FC<GridFieldProps> = ({
onDeleteField, onDeleteField,
labelAlign = 'right', labelAlign = 'right',
hasClipboard = false, hasClipboard = false,
duplicateFieldIds = new Set(),
}) => { }) => {
const columns = field.columns || 2; const columns = field.columns || 2;
const children = field.children || Array(columns).fill([]); const children = field.children || Array(columns).fill([]);
@ -67,6 +69,7 @@ const GridField: React.FC<GridFieldProps> = ({
gridId={field.id} gridId={field.id}
colIndex={colIndex} colIndex={colIndex}
hasClipboard={hasClipboard} hasClipboard={hasClipboard}
duplicateFieldIds={duplicateFieldIds}
/> />
); );
})} })}
@ -90,6 +93,7 @@ interface GridColumnProps {
gridId?: string; gridId?: string;
colIndex?: number; colIndex?: number;
hasClipboard?: boolean; hasClipboard?: boolean;
duplicateFieldIds?: Set<string>; // 冲突的字段ID集合
} }
const GridColumn: React.FC<GridColumnProps> = ({ const GridColumn: React.FC<GridColumnProps> = ({
@ -106,6 +110,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
gridId, gridId,
colIndex, colIndex,
hasClipboard = false, hasClipboard = false,
duplicateFieldIds = new Set(),
}) => { }) => {
const { setNodeRef, isOver } = useDroppable({ const { setNodeRef, isOver } = useDroppable({
id: dropId, id: dropId,
@ -127,6 +132,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
field={childField} field={childField}
isPreview={isPreview} isPreview={isPreview}
labelAlign={labelAlign} labelAlign={labelAlign}
duplicateFieldIds={duplicateFieldIds}
/> />
</div> </div>
))} ))}
@ -210,9 +216,13 @@ const GridColumn: React.FC<GridColumnProps> = ({
style={{ style={{
marginBottom: 8, marginBottom: 8,
padding: 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, borderRadius: 4,
background: '#fff', background: duplicateFieldIds.has(childField.id) ? '#fff1f0' : '#fff', // 冲突:浅红色背景
position: 'relative', position: 'relative',
cursor: 'pointer', cursor: 'pointer',
}} }}
@ -221,6 +231,22 @@ const GridColumn: React.FC<GridColumnProps> = ({
onSelectField?.(childField.id); 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} /> <FieldRenderer field={childField} isPreview={isPreview} labelAlign={labelAlign} />
{/* 字段操作按钮 */} {/* 字段操作按钮 */}

View File

@ -32,6 +32,7 @@ interface PropertyPanelProps {
onFieldChange: (field: FieldConfig) => void; onFieldChange: (field: FieldConfig) => void;
onFormConfigChange: (newConfig: Partial<FormConfig>) => void; onFormConfigChange: (newConfig: Partial<FormConfig>) => void;
allFields?: FieldConfig[]; // 所有字段,用于联动规则配置 allFields?: FieldConfig[]; // 所有字段,用于联动规则配置
fieldNames?: Map<string, string[]>; // 字段名映射,用于冲突检测
} }
const PropertyPanel: React.FC<PropertyPanelProps> = ({ const PropertyPanel: React.FC<PropertyPanelProps> = ({
@ -40,6 +41,7 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
onFieldChange, onFieldChange,
onFormConfigChange, onFormConfigChange,
allFields = [], allFields = [],
fieldNames,
}) => { }) => {
const [form] = Form.useForm(); const [form] = Form.useForm();
@ -195,7 +197,32 @@ const PropertyPanel: React.FC<PropertyPanelProps> = ({
<Input placeholder="请输入字段标签" /> <Input placeholder="请输入字段标签" />
</Form.Item> </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="请输入字段名称(英文)" /> <Input placeholder="请输入字段名称(英文)" />
</Form.Item> </Form.Item>
</> </>

View File

@ -157,6 +157,18 @@
background: #f0f5ff; 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 { .form-designer-field-drag-handle {
color: #8c8c8c; color: #8c8c8c;
font-size: 16px; font-size: 16px;