表单设计器

This commit is contained in:
dengqichen 2025-10-24 01:28:04 +08:00
parent e63ca34702
commit 03d4d21424
4 changed files with 157 additions and 20 deletions

View File

@ -31,10 +31,13 @@ import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
pointerWithin,
rectIntersection,
closestCenter
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
@ -119,6 +122,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
// 正在拖拽的元素ID
const [activeId, setActiveId] = useState<string | null>(null);
// 拖拽悬停的目标ID用于显示插入指示器
const [overId, setOverId] = useState<string | null>(null);
// 拖拽传感器 - 添加激活约束,减少误触和卡顿
const sensors = useSensors(
@ -129,12 +135,49 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
})
);
// 自定义碰撞检测策略:优先使用指针检测,避免栅格内部区域干扰排序
const customCollisionDetection = useCallback((args: any) => {
// 首先尝试使用 pointerWithin - 只有指针真正进入区域时才触发
const pointerCollisions = pointerWithin(args);
if (pointerCollisions.length > 0) {
// 过滤掉栅格列grid-xxx-col-xxx优先选择画布或字段
const nonGridCollisions = pointerCollisions.filter((collision: any) => {
const id = collision.id.toString();
return !id.startsWith('grid-') || id === 'canvas' || id === 'canvas-bottom';
});
if (nonGridCollisions.length > 0) {
return nonGridCollisions;
}
// 如果只有栅格列,说明确实是要拖入栅格
return pointerCollisions;
}
// 如果 pointerWithin 没有结果,使用 rectIntersection 作为后备
const rectCollisions = rectIntersection(args);
if (rectCollisions.length > 0) {
return rectCollisions;
}
// 最后使用 closestCenter 作为兜底
return closestCenter(args);
}, []);
// 生成唯一 ID
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 处理拖拽开始
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id.toString());
setOverId(null);
}, []);
// 处理拖拽悬停
const handleDragOver = useCallback((event: DragOverEvent) => {
const { over } = event;
setOverId(over ? over.id.toString() : null);
}, []);
// 处理拖拽结束
@ -143,6 +186,7 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
// 清除拖拽状态
setActiveId(null);
setOverId(null);
if (!over) return;
@ -207,8 +251,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
setFields((prev) => {
const newFields = [...prev];
// 如果拖到画布空白处,添加到末尾
if (over.id === 'canvas') {
// 如果拖到画布空白处或底部区域,添加到末尾
if (over.id === 'canvas' || over.id === 'canvas-bottom' || overData?.isBottom) {
newFields.push(newField);
} else {
// 如果拖到某个字段上,插入到该字段之后
@ -633,8 +677,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
collisionDetection={closestCenter}
collisionDetection={customCollisionDetection}
>
<div className="form-designer-body">
{/* 左侧组件面板 */}
@ -653,6 +698,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
onPasteToGrid={handlePasteToGrid}
labelAlign={formConfig.labelAlign}
hasClipboard={!!clipboard}
activeId={activeId}
overId={overId}
/>
{/* 右侧属性面板 */}

View File

@ -24,6 +24,8 @@ interface DesignCanvasProps {
onDeleteField: (fieldId: string) => void;
labelAlign?: 'left' | 'right' | 'top';
hasClipboard?: boolean;
activeId?: string | null; // 正在拖拽的元素ID
overId?: string | null; // 拖拽悬停的目标ID
}
const DesignCanvas: React.FC<DesignCanvasProps> = ({
@ -36,6 +38,8 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
onDeleteField,
labelAlign = 'right',
hasClipboard = false,
activeId,
overId,
}) => {
const { setNodeRef } = useDroppable({
id: 'canvas',
@ -91,22 +95,29 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
>
<div className="form-designer-field-list">
{fields.map((field) => (
<FieldItem
key={field.id}
field={field}
isSelected={field.id === selectedFieldId}
onSelect={() => onSelectField(field.id)}
onCopy={() => onCopyField(field.id)}
onDelete={() => onDeleteField(field.id)}
selectedFieldId={selectedFieldId}
onSelectField={onSelectField}
onCopyField={onCopyField}
onPasteToGrid={onPasteToGrid}
onDeleteField={onDeleteField}
labelAlign={labelAlign}
hasClipboard={hasClipboard}
/>
<React.Fragment key={field.id}>
{/* 插入指示器 - 显示在拖拽悬停的字段上方 */}
{activeId && overId === field.id && activeId !== field.id && (
<DropIndicator text="释放以插入到此处" />
)}
<FieldItem
field={field}
isSelected={field.id === selectedFieldId}
onSelect={() => onSelectField(field.id)}
onCopy={() => onCopyField(field.id)}
onDelete={() => onDeleteField(field.id)}
selectedFieldId={selectedFieldId}
onSelectField={onSelectField}
onCopyField={onCopyField}
onPasteToGrid={onPasteToGrid}
onDeleteField={onDeleteField}
labelAlign={labelAlign}
hasClipboard={hasClipboard}
/>
</React.Fragment>
))}
<CanvasBottomDropZone />
</div>
</SortableContext>
)}
@ -115,5 +126,66 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
);
};
// 插入指示器组件
interface DropIndicatorProps {
text: string;
}
const DropIndicator: React.FC<DropIndicatorProps> = ({ text }) => {
return (
<div
style={{
height: 40,
margin: '8px 0',
border: '2px dashed #1890ff',
borderRadius: 6,
background: 'linear-gradient(90deg, #f0f5ff 0%, #e6f4ff 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1890ff',
fontSize: 13,
fontWeight: 500,
transition: 'all 0.2s ease',
animation: 'dropIndicatorPulse 1.5s ease-in-out infinite',
}}
>
{text}
</div>
);
};
// 画布底部拖放区域组件
const CanvasBottomDropZone: React.FC = () => {
const { setNodeRef, isOver } = useDroppable({
id: 'canvas-bottom',
data: {
accept: 'field',
isBottom: true, // 标识这是底部区域
},
});
return (
<div
ref={setNodeRef}
style={{
minHeight: 60,
marginTop: 16,
border: `2px dashed ${isOver ? '#1890ff' : 'transparent'}`,
borderRadius: 6,
background: isOver ? '#f0f5ff' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isOver ? '#1890ff' : '#d9d9d9',
fontSize: 12,
transition: 'all 0.3s ease',
}}
>
{isOver ? '释放以添加到底部' : '拖拽到此处添加到底部'}
</div>
);
};
export default DesignCanvas;

View File

@ -141,7 +141,7 @@ const GridColumn: React.FC<GridColumnProps> = ({
<div
ref={setNodeRef}
style={{
minHeight: 100,
minHeight: fields.length === 0 ? 80 : 'auto',
padding: 12,
border: `2px dashed ${isOver ? '#1890ff' : '#d9d9d9'}`,
borderRadius: 6,

View File

@ -133,7 +133,7 @@
align-items: flex-start;
gap: 12px;
padding: 16px;
margin-bottom: 12px;
margin-bottom: 20px;
background: #fff;
border: 2px solid #e8e8e8;
border-radius: 8px;
@ -141,6 +141,12 @@
transition: all 0.3s ease;
}
/* 栅格布局字段项特殊样式 - 更大的间距 */
.form-designer-field-item:has(.ant-row) {
margin-bottom: 24px;
padding: 20px;
}
.form-designer-field-item:hover {
border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
@ -263,3 +269,15 @@
height: 24px !important;
}
/* 插入指示器脉冲动画 */
@keyframes dropIndicatorPulse {
0%, 100% {
opacity: 0.8;
box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.4);
}
50% {
opacity: 1;
box-shadow: 0 0 0 4px rgba(24, 144, 255, 0.1);
}
}