表单设计器
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 { 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>
|
||||||
|
|
||||||
|
|||||||
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
|
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,18 +64,53 @@ 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) {
|
||||||
|
// 5️⃣ onError 钩子 - 错误处理
|
||||||
|
if (onError) {
|
||||||
|
try {
|
||||||
|
await onError(error);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('onError hook error:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
message.error('请填写必填项');
|
message.error('请填写必填项');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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] 渲染 textarea,rows:', field.rows, 'field:', field.name);
|
|
||||||
return (
|
return (
|
||||||
<TextArea
|
<TextArea
|
||||||
rows={field.rows || 4}
|
rows={field.rows || 4}
|
||||||
|
|||||||
@ -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} />
|
||||||
|
|
||||||
{/* 字段操作按钮 */}
|
{/* 字段操作按钮 */}
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user