表单设计器

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, DndContext,
DragEndEvent, DragEndEvent,
DragStartEvent, DragStartEvent,
DragOverEvent,
DragOverlay, DragOverlay,
PointerSensor, PointerSensor,
useSensor, useSensor,
useSensors, useSensors,
pointerWithin,
rectIntersection,
closestCenter closestCenter
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable'; import { arrayMove } from '@dnd-kit/sortable';
@ -120,6 +123,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
// 正在拖拽的元素ID // 正在拖拽的元素ID
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
// 拖拽悬停的目标ID用于显示插入指示器
const [overId, setOverId] = useState<string | null>(null);
// 拖拽传感器 - 添加激活约束,减少误触和卡顿 // 拖拽传感器 - 添加激活约束,减少误触和卡顿
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
@ -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 // 生成唯一 ID
const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const generateId = () => `field_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// 处理拖拽开始 // 处理拖拽开始
const handleDragStart = useCallback((event: DragStartEvent) => { const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id.toString()); 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); setActiveId(null);
setOverId(null);
if (!over) return; if (!over) return;
@ -207,8 +251,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
setFields((prev) => { setFields((prev) => {
const newFields = [...prev]; const newFields = [...prev];
// 如果拖到画布空白处,添加到末尾 // 如果拖到画布空白处或底部区域,添加到末尾
if (over.id === 'canvas') { if (over.id === 'canvas' || over.id === 'canvas-bottom' || overData?.isBottom) {
newFields.push(newField); newFields.push(newField);
} else { } else {
// 如果拖到某个字段上,插入到该字段之后 // 如果拖到某个字段上,插入到该字段之后
@ -633,8 +677,9 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
<DndContext <DndContext
sensors={sensors} sensors={sensors}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
collisionDetection={closestCenter} collisionDetection={customCollisionDetection}
> >
<div className="form-designer-body"> <div className="form-designer-body">
{/* 左侧组件面板 */} {/* 左侧组件面板 */}
@ -653,6 +698,8 @@ const FormDesigner: React.FC<FormDesignerProps> = ({
onPasteToGrid={handlePasteToGrid} onPasteToGrid={handlePasteToGrid}
labelAlign={formConfig.labelAlign} labelAlign={formConfig.labelAlign}
hasClipboard={!!clipboard} hasClipboard={!!clipboard}
activeId={activeId}
overId={overId}
/> />
{/* 右侧属性面板 */} {/* 右侧属性面板 */}

View File

@ -24,6 +24,8 @@ interface DesignCanvasProps {
onDeleteField: (fieldId: string) => void; onDeleteField: (fieldId: string) => void;
labelAlign?: 'left' | 'right' | 'top'; labelAlign?: 'left' | 'right' | 'top';
hasClipboard?: boolean; hasClipboard?: boolean;
activeId?: string | null; // 正在拖拽的元素ID
overId?: string | null; // 拖拽悬停的目标ID
} }
const DesignCanvas: React.FC<DesignCanvasProps> = ({ const DesignCanvas: React.FC<DesignCanvasProps> = ({
@ -36,6 +38,8 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
onDeleteField, onDeleteField,
labelAlign = 'right', labelAlign = 'right',
hasClipboard = false, hasClipboard = false,
activeId,
overId,
}) => { }) => {
const { setNodeRef } = useDroppable({ const { setNodeRef } = useDroppable({
id: 'canvas', id: 'canvas',
@ -91,22 +95,29 @@ const DesignCanvas: React.FC<DesignCanvasProps> = ({
> >
<div className="form-designer-field-list"> <div className="form-designer-field-list">
{fields.map((field) => ( {fields.map((field) => (
<FieldItem <React.Fragment key={field.id}>
key={field.id} {/* 插入指示器 - 显示在拖拽悬停的字段上方 */}
field={field} {activeId && overId === field.id && activeId !== field.id && (
isSelected={field.id === selectedFieldId} <DropIndicator text="释放以插入到此处" />
onSelect={() => onSelectField(field.id)} )}
onCopy={() => onCopyField(field.id)}
onDelete={() => onDeleteField(field.id)} <FieldItem
selectedFieldId={selectedFieldId} field={field}
onSelectField={onSelectField} isSelected={field.id === selectedFieldId}
onCopyField={onCopyField} onSelect={() => onSelectField(field.id)}
onPasteToGrid={onPasteToGrid} onCopy={() => onCopyField(field.id)}
onDeleteField={onDeleteField} onDelete={() => onDeleteField(field.id)}
labelAlign={labelAlign} selectedFieldId={selectedFieldId}
hasClipboard={hasClipboard} onSelectField={onSelectField}
/> onCopyField={onCopyField}
onPasteToGrid={onPasteToGrid}
onDeleteField={onDeleteField}
labelAlign={labelAlign}
hasClipboard={hasClipboard}
/>
</React.Fragment>
))} ))}
<CanvasBottomDropZone />
</div> </div>
</SortableContext> </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; export default DesignCanvas;

View File

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

View File

@ -133,7 +133,7 @@
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
padding: 16px; padding: 16px;
margin-bottom: 12px; margin-bottom: 20px;
background: #fff; background: #fff;
border: 2px solid #e8e8e8; border: 2px solid #e8e8e8;
border-radius: 8px; border-radius: 8px;
@ -141,6 +141,12 @@
transition: all 0.3s ease; transition: all 0.3s ease;
} }
/* 栅格布局字段项特殊样式 - 更大的间距 */
.form-designer-field-item:has(.ant-row) {
margin-bottom: 24px;
padding: 20px;
}
.form-designer-field-item:hover { .form-designer-field-item:hover {
border-color: #1890ff; border-color: #1890ff;
box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1); box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
@ -263,3 +269,15 @@
height: 24px !important; 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);
}
}