当前位置: 首页 > ds >正文

react+antd+表格拖拽排序以及上移、下移、移到顶部、移到底部

表格拖拽排序与上下移动功能实现指南

先看效果

概述

本文档详细介绍了如何在React项目中实现表格的拖拽排序功能,同时提供上下移动、批量删除等操作。该实现基于Ant Design Table组件和@dnd-kit拖拽库。

功能特性

  • 拖拽排序:支持通过鼠标拖拽调整表格行顺序
  • 按钮移动:提供上移、下移、移到顶部、移到底部按钮
  • 批量操作:支持多选行进行批量删除
  • 实时更新:支持立即更新模式和确认更新模式
  • 用户体验:防误触设计,流畅的拖拽动画效果

技术栈

{"dependencies": {"@dnd-kit/core": "^6.0.0","@dnd-kit/sortable": "^7.0.0", "@dnd-kit/utilities": "^3.2.0","antd": "^5.0.0","react": "^18.0.0"}
}

核心实现

1. 拖拽行组件(DraggableRow)

const DraggableRow = ({ children, ...props }: any) => {const {attributes,listeners,setNodeRef,transform,transition,isDragging,} = useSortable({id: props['data-row-key'],});const style: React.CSSProperties = {...props.style,transform: CSS.Transform.toString(transform),transition,cursor: 'grab',...(isDragging ? { position: 'relative', zIndex: 9999,cursor: 'grabbing'} : {}),};// 防止交互元素触发拖拽const dragListeners = {...listeners,onMouseDown: (e: MouseEvent) => {const target = e.target as HTMLElement;const isInteractiveElement = target.closest('input, button, .ant-checkbox, .ant-btn, [role="button"]');if (!isInteractiveElement) {listeners?.onMouseDown?.(e);}},};return (<tr {...props} {...attributes}{...dragListeners}ref={setNodeRef} style={style}className="draggable-table-row">{children}</tr>);
};

2. 拖拽上下文配置

const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8, // 拖拽距离阈值,避免误触},}),useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates,})
);// 拖拽结束处理
const handleDragEnd = (event: any) => {const { active, over } = event;if (active.id !== over?.id) {setLocalDataSource((prev) => {const oldIndex = prev.findIndex((item) => item.ID === active.id);const newIndex = prev.findIndex((item) => item.ID === over.id);return arrayMove(prev, oldIndex, newIndex);});}
};

3. 按钮移动功能

// 上移
const moveUp = (index: number) => {if (index === 0) return;const newDataSource = [...localDataSource];[newDataSource[index], newDataSource[index - 1]] = [newDataSource[index - 1], newDataSource[index]];setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}
};// 下移
const moveDown = (index: number) => {if (index === localDataSource.length - 1) return;const newDataSource = [...localDataSource];[newDataSource[index], newDataSource[index + 1]] = [newDataSource[index + 1], newDataSource[index]];setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}
};// 移到顶部
const moveToTop = (index: number) => {if (index === 0) return;const newDataSource = [...localDataSource];const item = newDataSource.splice(index, 1)[0];newDataSource.unshift(item);setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}
};// 移到底部
const moveToBottom = (index: number) => {if (index === localDataSource.length - 1) return;const newDataSource = [...localDataSource];const item = newDataSource.splice(index, 1)[0];newDataSource.push(item);setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}
};

4. 表格配置

<DndContextsensors={sensors}collisionDetection={closestCenter}onDragEnd={handleDragEnd}
><SortableContext items={items} strategy={verticalListSortingStrategy}><Tablecolumns={columns}dataSource={localDataSource}rowKey="ID"pagination={false}rowSelection={rowSelection}components={{body: {row: DraggableRow,},}}scroll={{ y: 580 }}size="middle"rowClassName="draggable-row"/></SortableContext>
</DndContext>

样式优化

.draggable-row td {height: 60px !important;vertical-align: middle !important;padding: 12px 16px !important;
}.draggable-row:hover td {background-color: #f5f5f5;
}.draggable-table-row {user-select: none;
}.draggable-table-row:hover {background-color: #f5f5f5 !important;
}.draggable-table-row:active {background-color: #e6f7ff !important;
}/* 保持交互元素的正常鼠标指针 */
.draggable-table-row .ant-checkbox,
.draggable-table-row .ant-btn,
.draggable-table-row button,
.draggable-table-row input {cursor: pointer !important;
}/* checkbox列对齐 */
.ant-table-selection-column {text-align: center !important;padding-left: 16px !important;padding-right: 8px !important;
}.ant-table-thead .ant-table-selection-column {text-align: center !important;
}/* 序号列对齐 */
.ant-table-tbody tr td:nth-child(2) {text-align: center !important;padding-left: 8px !important;padding-right: 8px !important;
}.ant-table-thead tr th:nth-child(2) {text-align: center !important;padding-left: 8px !important;padding-right: 8px !important;
}

使用方法

import PaperSortDeleteModal from './components/PaperSortDeleteModal';const MyComponent = () => {const [visible, setVisible] = useState(false);const [dataSource, setDataSource] = useState([]);const handleConfirm = (newList) => {setDataSource(newList);console.log('更新后的数据:', newList);};return (<><Button onClick={() => setVisible(true)}>排序管理</Button><PaperSortDeleteModalvisible={visible}onCancel={() => setVisible(false)}onConfirm={handleConfirm}dataSource={dataSource}immediateUpdate={false} // 是否立即更新/></>);
};

接口说明

Props

参数类型默认值说明
visiblebooleanfalse模态框显示状态
onCancel() => void-取消回调
onConfirm(newList: QuestionData[]) => void-确认回调
dataSourceQuestionData[][]数据源
immediateUpdatebooleanfalse是否立即更新模式

数据结构

interface QuestionData {ID: string;TITLE: string;// ... 其他字段
}

最佳实践

1. 性能优化

  • 使用 useMemo 缓存计算结果
  • 合理设置拖拽激活距离,避免误触
  • 大数据量时考虑虚拟滚动

2. 用户体验

  • 提供清晰的拖拽视觉反馈
  • 防止交互元素触发拖拽
  • 支持键盘操作
  • 添加操作确认提示

3. 错误处理

const handleDragEnd = (event: any) => {try {const { active, over } = event;if (active.id !== over?.id) {// 拖拽逻辑}} catch (error) {console.error('拖拽操作失败:', error);openNotification('错误', '排序操作失败', 'error');}
};

注意事项

  1. 唯一ID:确保每行数据都有唯一的ID作为拖拽标识
  2. 状态管理:使用本地状态管理拖拽过程中的数据变化
  3. 事件冲突:防止拖拽事件与其他交互事件冲突
  4. 浏览器兼容性:@dnd-kit库已处理大部分兼容性问题
  5. 移动端适配:考虑移动端的触摸操作体验

扩展功能

  • 分组拖拽:支持跨分组拖拽
  • 条件限制:添加拖拽条件限制
  • 撤销重做:实现操作历史记录
  • 批量移动:支持批量选择后整体移动

实际应用示例

基于提供的 PaperSortDeleteModal 组件,以下是一个完整的使用示例:

import React, { useState } from 'react';
import { Button, Space } from 'antd';
import PaperSortDeleteModal from './components/PaperSortDeleteModal';interface QuestionData {ID: string;TITLE: string;TYPE?: string;DIFFICULTY?: string;
}const PaperDesignPage: React.FC = () => {const [modalVisible, setModalVisible] = useState(false);const [questionList, setQuestionList] = useState<QuestionData[]>([{ ID: '1', TITLE: '这是第一道题目', TYPE: 'single', DIFFICULTY: 'easy' },{ ID: '2', TITLE: '这是第二道题目', TYPE: 'multiple', DIFFICULTY: 'medium' },{ ID: '3', TITLE: '这是第三道题目', TYPE: 'judge', DIFFICULTY: 'hard' },]);const handleSortConfirm = (newList: QuestionData[]) => {setQuestionList(newList);console.log('排序后的结果:', newList);};return (<div style={{ padding: '20px' }}><Space><Button type="primary" onClick={() => setModalVisible(true)}>排序与管理</Button></Space><PaperSortDeleteModalvisible={modalVisible}onCancel={() => setModalVisible(false)}onConfirm={handleSortConfirm}dataSource={questionList}immediateUpdate={false}/></div>);
};export default PaperDesignPage;
import React, { useState, useMemo } from 'react';
import { Modal, Table, Button, Space, message, Popconfirm, Tooltip } from 'antd';
import { DeleteOutlined, DragOutlined, UpOutlined, DownOutlined, VerticalAlignTopOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {DndContext,closestCenter,KeyboardSensor,PointerSensor,useSensor,useSensors,
} from '@dnd-kit/core';
import {arrayMove,SortableContext,sortableKeyboardCoordinates,verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { openNotification } from "@/services/NotificationService";
import { QuestionData } from '../PaperDesignProvider';interface PaperSortDeleteModalProps {visible: boolean;onCancel: () => void;onConfirm: (newList: QuestionData[]) => void;dataSource: QuestionData[];/** 是否立即更新模式,true:操作后立即更新外部数据,false:需要点击确定按钮才更新 */immediateUpdate?: boolean;
}// 拖拽行组件
const DraggableRow = ({ children, ...props }: any) => {const {attributes,listeners,setNodeRef,transform,transition,isDragging,} = useSortable({id: props['data-row-key'],});const style: React.CSSProperties = {...props.style,transform: CSS.Transform.toString(transform),transition,cursor: 'grab',...(isDragging ? { position: 'relative', zIndex: 9999,cursor: 'grabbing'} : {}),};// 过滤掉会阻止拖拽的事件处理器const dragListeners = {...listeners,onMouseDown: (e: MouseEvent) => {// 如果点击的是交互元素,不触发拖拽const target = e.target as HTMLElement;const isInteractiveElement = target.closest('input, button, .ant-checkbox, .ant-btn, [role="button"]');if (!isInteractiveElement) {listeners?.onMouseDown?.(e);}},};return (<tr {...props} {...attributes}{...dragListeners}ref={setNodeRef} style={style}className={`${props.className || ''} draggable-table-row`}>{React.Children.map(children, (child, index) => {// if (index === 1) { // 第二列(序号列)添加拖拽图标作为视觉提示//   return React.cloneElement(child, {//     children: (//       <div style={{ //         display: 'flex', //         alignItems: 'center', //         justifyContent: 'center',//         gap: 6,//         width: '100%'//       }}>//         <DragOutlined style={{ color: '#999', fontSize: '12px' }} />//         <span>{child.props.children}</span>//       </div>//     ),//   });// }return child;})}</tr>);
};const PaperSortDeleteModal: React.FC<PaperSortDeleteModalProps> = ({visible,onCancel,onConfirm,dataSource,immediateUpdate = false,
}) => {const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);const [localDataSource, setLocalDataSource] = useState<QuestionData[]>(dataSource);// 重置状态当模态框打开时React.useEffect(() => {if (visible) {setLocalDataSource([...dataSource]);setSelectedRowKeys([]);}}, [visible, dataSource]);const sensors = useSensors(useSensor(PointerSensor, {activationConstraint: {distance: 8, // 拖拽距离阈值,避免误触},}),useSensor(KeyboardSensor, {coordinateGetter: sortableKeyboardCoordinates,}));// 拖拽结束处理const handleDragEnd = (event: any) => {const { active, over } = event;if (active.id !== over?.id) {setLocalDataSource((prev) => {const oldIndex = prev.findIndex((item) => item.ID === active.id);const newIndex = prev.findIndex((item) => item.ID === over.id);return arrayMove(prev, oldIndex, newIndex);});}};// 移动功能const moveUp = (index: number) => {if (index === 0) {return;}const newDataSource = [...localDataSource];[newDataSource[index], newDataSource[index - 1]] = [newDataSource[index - 1], newDataSource[index]];setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}};const moveDown = (index: number) => {if (index === localDataSource.length - 1) {return;}const newDataSource = [...localDataSource];[newDataSource[index], newDataSource[index + 1]] = [newDataSource[index + 1], newDataSource[index]];setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}};const moveToTop = (index: number) => {if (index === 0) {return;}const newDataSource = [...localDataSource];const item = newDataSource.splice(index, 1)[0];newDataSource.unshift(item);setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}};const moveToBottom = (index: number) => {if (index === localDataSource.length - 1) {return;}const newDataSource = [...localDataSource];const item = newDataSource.splice(index, 1)[0];newDataSource.push(item);setLocalDataSource(newDataSource);if (immediateUpdate) {onConfirm(newDataSource);}};// 批量删除const handleBatchDelete = () => {if (selectedRowKeys.length === 0) {openNotification('提示', '请选择要删除的试题', 'warning');return;}const newDataSource = localDataSource.filter((item) => !selectedRowKeys.includes(item.ID));setLocalDataSource(newDataSource);setSelectedRowKeys([]);openNotification('成功', `已删除 ${selectedRowKeys.length} 道试题`, 'success');if (immediateUpdate) {onConfirm(newDataSource);}};// 单个删除const handleSingleDelete = (id: string) => {const newDataSource = localDataSource.filter((item) => item.ID !== id);setLocalDataSource(newDataSource);openNotification('成功', '删除成功', 'success');if (immediateUpdate) {onConfirm(newDataSource);}};// 确认保存const handleConfirm = () => {onConfirm(localDataSource);onCancel();};const columns: ColumnsType<QuestionData> = [{title: '序号',width: 80,dataIndex: 'dataIndex',align: 'center',render: (_, __, index) => index + 1,},{title: '操作',key: 'action',width: 160,align: 'left',render: (_, record, index) => (<Space size={4}>{index > 0 && (<Tooltip title="移到顶部"><Button type="text" icon={<VerticalAlignTopOutlined />} size="small"onClick={() => moveToTop(index)}/></Tooltip>)}{index > 0 && (<Tooltip title="上移"><Button type="text" icon={<UpOutlined />} size="small"onClick={() => moveUp(index)}/></Tooltip>)}{index < localDataSource.length - 1 && (<Tooltip title="下移"><Button type="text" icon={<DownOutlined />} size="small"onClick={() => moveDown(index)}/></Tooltip>)}{index < localDataSource.length - 1 && (<Tooltip title="移到底部"><Button type="text" icon={<VerticalAlignBottomOutlined />} size="small"onClick={() => moveToBottom(index)}/></Tooltip>)}<Popconfirmtitle="确定要删除这道试题吗?"onConfirm={() => handleSingleDelete(record.ID)}okText="确定"cancelText="取消"><Tooltip title="删除"><Button type="text" danger icon={<DeleteOutlined />} size="small"></Button></Tooltip></Popconfirm></Space>),},{title: '试题标题',dataIndex: 'TITLE',key: 'TITLE',ellipsis: true,render:(text:string)=>{return <div>这是测试内容,这是测试内容这是测试内容这是测试内容这是测试内容这是测试内容这是测试内容</div>}},];const rowSelection = {selectedRowKeys,onChange: (keys: React.Key[]) => {setSelectedRowKeys(keys as string[]);},};const items = useMemo(() => localDataSource.map(item => item.ID), [localDataSource]);return (<><style>{`.draggable-row td {height: 60px !important;vertical-align: middle !important;padding: 12px 16px !important;}.draggable-row:hover td {background-color: #f5f5f5;}.draggable-table-row {user-select: none;}.draggable-table-row:hover {background-color: #f5f5f5 !important;}.draggable-table-row:active {background-color: #e6f7ff !important;}/* 保持交互元素的正常鼠标指针 */.draggable-table-row .ant-checkbox,.draggable-table-row .ant-btn,.draggable-table-row button,.draggable-table-row input {cursor: pointer !important;}/* checkbox列对齐 */.ant-table-selection-column {text-align: center !important;padding-left: 16px !important;padding-right: 8px !important;}.ant-table-thead .ant-table-selection-column {text-align: center !important;}/* 序号列对齐 */.ant-table-tbody tr td:nth-child(2) {text-align: center !important;padding-left: 8px !important;padding-right: 8px !important;}.ant-table-thead tr th:nth-child(2) {text-align: center !important;padding-left: 8px !important;padding-right: 8px !important;}`}</style><Modaltitle="试题排序与删除"open={visible}onCancel={onCancel}onOk={handleConfirm}width={1200}height={800}okText="确定"cancelText="取消"destroyOnHidden styles={{body: {padding: '20px',height: '700px'},footer:{paddingTop: '16px'}}}><Space direction="vertical" style={{ width: '100%' }} size="middle"><div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}><div><span>{localDataSource.length} 道试题</span>{selectedRowKeys.length > 0 && (<span style={{ marginLeft: 16, color: '#1890ff' }}>已选择 {selectedRowKeys.length}</span>)}</div><Space><Popconfirmtitle={`确定要删除选中的 ${selectedRowKeys.length} 道试题吗?`}onConfirm={handleBatchDelete}okText="确定"cancelText="取消"disabled={selectedRowKeys.length === 0}><Button danger icon={<DeleteOutlined />}disabled={selectedRowKeys.length === 0}>批量删除</Button></Popconfirm></Space></div><DndContextsensors={sensors}collisionDetection={closestCenter}onDragEnd={handleDragEnd}><SortableContext items={items} strategy={verticalListSortingStrategy}><Tablecolumns={columns}dataSource={localDataSource}rowKey="ID"pagination={false}rowSelection={rowSelection}components={{body: {row: DraggableRow,},}}scroll={{ y: 580 }}size="middle"rowClassName={() => 'draggable-row'}style={{'--row-height': '60px'} as React.CSSProperties}/></SortableContext></DndContext>{/* <div style={{ color: '#666', fontSize: 12 }}><div>• 点击确定保存修改,取消则放弃修改</div></div> */}</Space></Modal></>);
};export default PaperSortDeleteModal; 

这个实现提供了完整的表格拖拽排序功能,同时兼顾了用户体验和代码可维护性。可以根据具体需求进行定制和扩展。

http://www.xdnf.cn/news/15930.html

相关文章:

  • react17更新哪些新特性
  • ARINC818协议综述
  • 48Days-Day03 | 删除公共字符,两个链表的第一个公共结点,mari和shiny
  • uniapp相关地图 API调用
  • servicemesh 学习
  • 实战分享:Web3 前端开发Defi项目
  • [硬件电路-39]:激光光路的光信号处理、模拟电路的电信号处理、数字电路的电信号处理、软件的信号处理,有哪些共通的操作、运算、变换?
  • 06-人机共生:Prompt之外的思考
  • 【RK3576】【Android14】USB开发调试
  • k8s 基本架构
  • 【小沐学GIS】基于Rust绘制三维数字地球Earth(Rust、OpenGL、GIS)
  • 完美解决 Ubuntu 中自定义启动器图标重复的问题(以 MATLAB 为例)
  • bash方式启动模型训练
  • python基础复习
  • 高压电工作业证考试核心考点:电气安全基础篇
  • 响应式单位rpx及搭配使用UI产品工具
  • 风格多样!5 个覆盖全风格的素材网站,创作有新意
  • AUTOSAR进阶图解==>AUTOSAR_SWS_DiagnosticOverIP
  • 创建套接字并bind的详细过程
  • 从 Server.xml 到字节码:Tomcat 内核全景与请求旅程 10 000 字深剖
  • MinIO深度解析:从核心特性到Spring Boot实战集成
  • 数据结构与算法之美:拓扑排序
  • 外观设计模式
  • Uniapp之键盘弹窗
  • win10连接鼠标自动关闭触摸板/win10关闭触摸板(笔记本)
  • 智能合约代理与批量调用优化:最小代理与MultiCall的应用
  • android studio libs.versions.toml 配置
  • 嵌入式硬件中电感的基本原理与实现详解
  • CSS篇——第二章 六十五项关键技能(下篇)
  • Kotlin方差