拖拽排序后端算法

577 阅读5分钟

说明

框架使用的是 django-vue3-admin

最近在写项目的时候,做了一个资产类别的表,需要实现多层级的嵌套,前端使用tree组件展示,并且需要实现拖拽排序的功能 前端只要是布局,后端采用了 取中值法 的算法来计算排序,核心算法在views.py里面的 set_sort 函数

这里不能放视频,在知乎上有 知乎链接

后端

数据模型:models.py


class AssetClassModel(CoreModel):
    key = models.CharField(verbose_name="类别编号", max_length=64, unique=True)
    name = models.CharField(verbose_name="类别名字", max_length=64)
    sort = models.IntegerField(verbose_name="显示排序", default=1024)
    status = models.BooleanField(verbose_name="类别状态", default=True, null=True, blank=True)
    parent = models.ForeignKey(
        to="AssetClassModel",
        on_delete=models.CASCADE,
        default=None,
        verbose_name="上级类别",
        db_constraint=False,
        null=True,
        blank=True,
        help_text="上级类别",
    )

    class Meta:
        db_table = table_prefix + "asset_class"
        verbose_name = "资产类别"
        verbose_name_plural = verbose_name
        ordering = ("sort",)

序列化器:serializers.py

class AssetClassSerializer(CustomModelSerializer):
    """
    资产类别:查询序列化
    """

    class Meta:
        model = AssetClassModel
        fields = "__all__"


class AssetClassCreateUpdateSerializer(CustomModelSerializer):
    """
    资产类别:新增修改序列化
    """

    def create(self, validated_data):
        """
        创建数据的时候给设置默认排序
        赋值规则为:
            因为有多层级,这里在设置默认值的时候,只需要从当前层级开始即可
            当创建第一个元素时,默认位置排序是 1024,第二个元素为 2 * 1024 = 2048,增加第N个元素时,位置赋值为 N*1024
        """
        parent = validated_data.get("parent", None)
        validated_data["sort"] = (AssetClassModel.objects.filter(parent=parent).count() + 1) * 1024
        return super().create(validated_data)

    class Meta:
        model = AssetClassModel
        fields = "__all__"

视图:views.py

核心算法是 set_sort 前端会传三个参数过来:

  • drag: 被拖拽节点的ID
  • drop: 结束拖拽时最后进入的节点ID
  • drop_type: 被拖拽节点的放置位置(inner、before、after)

根据不同的位置计算不同的算法

  • inner:节点 drag 在 drop 里面
    • 只需要将 drag 的父级 ID 改成 drop 的 ID 即可
  • before:节点 drag 在 drop 之前
    • drag 的父级 ID 需要等于 drop 的父级ID,因为有可能 drag 是从其他层级拖拽过来的
    • 根据 drop 的排序找到他前面一行数据(排序小于 drop 的最后一条记录)
      • other_obj = self.queryset.filter(sort__lt=drop_obj.sort).last()
      • drag 的排序 = (other_obj 的排序 + drop 的排序) / 2
    • 这里还要考虑一种情况:当 drop 是第一行数据的时候,他没有上一条数据
      • drag 的排序 = drop 的排序 / 2
  • after:节点 drag 在 drop 之后
    • drag 的父级 ID 需要等于 drop 的父级ID,因为有可能 drag 是从其他层级拖拽过来的
    • 根据 drop 的排序找到他后面一行数据(排序大于 drop 的第一条记录)
      • other_obj = self.queryset.filter(sort__gt=drop_obj.sort).first()
      • drag 的排序 = (other_obj 的排序 + drop 的排序) / 2
    • 这里还要考虑一种情况:当 drop 是第一行数据的时候,他没有上一条数据
      • drag 的排序 = drop 的排序 +1024

在最后,每次保存之后,在判断 drop 的排序,如果不在 2 和 2147482622 之内,则重新计算 drag 所在层级的所有排序,规则也是从 1024 开始,以 1024 的倍数递增

class AssetClassViewSet(CustomModelViewSet):
    """
    资产类别:视图
    list:查询
    create:新增
    update:修改
    retrieve:单例
    destroy:删除
    """
    queryset = AssetClassModel.objects.all()
    serializer_class = AssetClassSerializer
    create_serializer_class = AssetClassCreateUpdateSerializer
    update_serializer_class = AssetClassCreateUpdateSerializer

    @action(methods=["PUT"], detail=False)
    def set_sort(self, request, *args, **kwargs):
        """
        前端拖拽排序,后端自动更新数据
        """
        drag = request.data.get("drag")
        drop_type = request.data.get("drop_type")
        drop = request.data.get("drop")
        drag_obj = self.queryset.get(id=drag)
        drop_obj = self.queryset.get(id=drop)

        if drop_type == "inner":
            # drag 在 drop 里面 的时候,drag 的父级等于 drop
            drag_obj.parent = drop_obj
            drag_obj.save()
        else:
            # 不管是 before 还是 after 当前移动的元素 drag_obj 的父级都等于 drop_obj 的父级
            drag_obj.parent = drop_obj.parent
            sort = None
            if drop_type == "before":
                # 在 drag 在 drop 之前:other_obj = 排序小于 drop 的最后一条记录
                other_obj = self.queryset.filter(sort__lt=drop_obj.sort).last()
                sort = (other_obj.sort + drop_obj.sort) / 2 if other_obj else drop_obj.sort / 2
            elif drop_type == "after":
                # 在 drag 在 drop 之前:other_obj = 排序大于 drop 的第一条记录
                other_obj = self.queryset.filter(sort__gt=drop_obj.sort).first()
                sort = (other_obj.sort + drop_obj.sort) / 2 if other_obj else drop_obj.sort + 1024
            drag_obj.sort = sort
            drag_obj.save()

            if not 2 < sort < 2147482622:
                # 如果 sort = 2则需要重新计算当前分组里面的排序
                sort_list = self.queryset.filter(parent=drag_obj.parent)
                for (index, item) in enumerate(sort_list):
                    item.sort = (index + 1) * 1024
                    item.save()
        return SuccessResponse(data=[], msg="设置成功")

前端

前端主要用的是el-tree组件

接口文件:api.js

/*
 * @Author: 木子-李 1537080775@qq.com
 * @Date: 2023-08-01 12:55:50
 * @LastEditors: 木子-李 1537080775@qq.com
 * @LastEditTime: 2023-08-01 21:59:56
 * @FilePath: \web\src\views\apps\assetManage\00_asset_class\api.ts
 * @Description:
 */

import { request } from '/@/utils/service';
import { UserPageQuery, AddReq, DelReq, EditReq, InfoReq } from '@fast-crud/fast-crud';

export const apiPrefix = '/api/asset/asset_class/';

/**
 * 列表查询
 * @param {*} query
 * @returns
 */
export function GetList(query: UserPageQuery) {
	return request({
		url: apiPrefix,
		method: 'get',
		params: query,
	});
}

/**
 * 单例查询
 * @param {*} query
 * @returns
 */
export function GetObj(id: InfoReq) {
	return request({
		url: apiPrefix + id,
		method: 'get',
	});
}

/**
 * 新增
 * @param {*} obj
 * @returns
 */
export function AddObj(obj: AddReq) {
	return request({
		url: apiPrefix,
		method: 'post',
		data: obj,
	});
}

/**
 * 修改
 * @param {*} obj
 * @returns
 */
export function UpdateObj(obj: EditReq) {
	return request({
		url: apiPrefix + obj.id + '/',
		method: 'put',
		data: obj,
	});
}

/**
 * 修改
 * @param {*} obj
 * @returns
 */
export function UpdateSort(obj: EditReq) {
	return request({
		url: apiPrefix + 'set_sort/',
		method: 'put',
		data: obj,
	});
}
/**
 * 删除
 * @param {*} id
 * @returns
 */
export function DelObj(obj: DelReq) {
	return request({
		url: apiPrefix + obj.id + '/',
		method: 'delete',
		data: obj,
	});
}

/**
 * 批量删除
 * @param {*} keys
 * @returns
 */
export function BatchDel(keys: []) {
	return request({
		url: apiPrefix + 'multiple_delete/',
		method: 'delete',
		data: { keys },
	});
}

配置文件:crud.js

/*
 * @Author: 木子-李 1537080775@qq.com
 * @Date: 2023-08-01 12:55:50
 * @LastEditors: 木子-李 1537080775@qq.com
 * @LastEditTime: 2023-08-01 22:34:18
 * @FilePath: \web\src\views\apps\assetManage\00_asset_class\crud.tsx
 * @Description:资产类别 - 配置页
 */

import * as api from './api';
import { dict, UserPageQuery, AddReq, DelReq, EditReq, compute, CreateCrudOptionsProps, CreateCrudOptionsRet } from '@fast-crud/fast-crud';
import { dictionary } from '/@/utils/dictionary';
import { successMessage } from '/@/utils/message';
import { ref } from 'vue';
import XEUtils from 'xe-utils';

export const createCrudOptions = function ({ crudExpose }: CreateCrudOptionsProps): CreateCrudOptionsRet {
	let treeData: any = ref([]);
	const parent = ref();
	const getTree = async () => {
		const params = { page: 1, limit: 999999 };
		const res = await api.GetList(params);
		treeData.value = [
			{
				id: undefined,
				name: '资产类别',
				children: XEUtils.toArrayTree(res.data, { parentKey: 'parent' }),
			},
		];
	};

	const pageRequest = async (query: UserPageQuery) => {
		return await api.GetList(query);
	};
	const editRequest = async ({ form, row }: EditReq) => {
		form.id = row.id;
		return await api.UpdateObj(form);
	};
	const delRequest = async ({ row }: DelReq) => {
		return await api.DelObj(row);
	};
	const addRequest = async ({ form }: AddReq) => {
		form.parent = parent.value;
		return await api.AddObj(form);
	};

	return {
		parent,
		treeData,
		getTree,
		crudOptions: {
			request: {
				pageRequest,
				addRequest,
				editRequest,
				delRequest,
			},
			pagination: {
				// show: false,
			},
			table: {
				rowKey: 'id',
				size: 'small',
				onRefreshed() {
					getTree();
				},
			},
			rowHandle: {
				fiexd: 'right',
				fixed: 'right',
				align: 'center', //文本居中
				width: 200,
				buttons: {
					view: {
						order: 1,
						show: false,
					},
					edit: {
						order: 2,
						iconRight: 'Edit',
						link: true,
					},
					remove: {
						order: 3,
						iconRight: 'Delete',
						link: true,
					},
				},
			},
			columns: {
				_index: {
					title: '序号',
					form: { show: false },
					column: {
						type: 'index',
						align: 'center',
						width: '70px',
						columnSetDisabled: true, //禁止在列设置中选择
					},
				},
				search: {
					title: '关键词',
					column: {
						show: false,
					},
					search: {
						show: true,
						component: {
							props: {
								clearable: true,
							},
							placeholder: '请输入关键词',
						},
					},
					form: {
						show: false,
						component: {
							props: {
								clearable: true,
							},
						},
					},
				},
				name: {
					title: '类别名称',
					sortable: true,
					search: {
						disabled: false,
						component: {
							props: {
								clearable: true,
							},
						},
					},
					width: 180,
					type: 'input',
					form: {
						rules: [
							// 表单校验规则
							{ required: true, message: '类别名称必填项' },
						],
						component: {
							span: 12,
							props: {
								clearable: true,
							},
							placeholder: '请输入类别名称',
						},
					},
				},
				key: {
					title: '类别标识',
					sortable: true,
					form: {
						rules: [
							// 表单校验规则
							{ required: true, message: '类别名称必填项' },
						],
						component: {
							props: {
								clearable: true,
							},
							placeholder: '请输入类别标识',
						},
					},
				},
				sort: {
					title: '排序',
					sortable: true,
					width: 80,
					type: 'number',
					form: {
						value: 1,
						component: {
							style: { width: '100%' },
							span: 12,
							placeholder: '请选择序号',
						},
					},
				},
				status: {
					title: '状态',
					sortable: true,
					search: {
						disabled: false,
					},
					type: 'dict-radio',
					column: {
						component: {
							name: 'fs-dict-switch',
							activeText: '',
							inactiveText: '',
							style: '--el-switch-on-color: #409eff; --el-switch-off-color: #dcdfe6',
							onChange: compute((context) => {
								return () => {
									api.UpdateObj(context.row).then((res: APIResponseData) => {
										successMessage(res.msg as string);
									});
								};
							}),
						},
					},
					form: {
						value: true,
					},
					dict: dict({
						data: dictionary('button_status_bool'),
					}),
				},
			},
		},
	};
};

页面文件:index.vue

用的 el-tree 组件 通过拖拽事件,调用后端 set_sort 函数

<!--
 * @Author: 木子-李 1537080775@qq.com
 * @Date: 2023-08-01 12:55:50
 * @LastEditors: 木子-李 1537080775@qq.com
 * @LastEditTime: 2023-08-02 00:10:41
 * @FilePath: \web\src\views\apps\assetManage\00_asset_class\index.vue
 * @Description: 资产类别 - 视图页
-->

<template>
	<fs-page>
		<splitpanes class="default-theme">
			<pane min-size="20" size="30">
				<div class="leftDiv">
					<el-tree
						ref="tree"
						draggable
						highlight-current
						default-expand-all
						:expand-on-click-node="false"
						:data="treeData"
						:props="defaultProps"
						:allow-drop="allowDrop"
						:allow-drag="allowDrag"
						@node-drop="handleDrop"
						@node-click="nodeClick"
					>
						<template #default="{ node, data }">
							<span class="custom-tree-node">
								<span>{{ node.label }}</span>
								<span class="button">
									<el-button-group size="small">
										<el-button icon="Plus" type="primary" @click="add($event, node, data)" link></el-button>
										<el-button icon="Edit" type="primary" @click="edit($event, node, data)" link v-if="data.id"></el-button>
										<el-button icon="Delete" type="primary" @click="remove($event, node, data)" link v-if="data.id"></el-button>
									</el-button-group>
								</span>
							</span> </template
					></el-tree>
				</div>
			</pane>
			<pane min-size="50">
				<div class="rightDiv"><fs-crud ref="crudRef" v-bind="crudBinding"></fs-crud></div>
			</pane>
		</splitpanes>
	</fs-page>
</template>

<script lang="ts" setup name="asset_class">
import { Splitpanes, Pane } from 'splitpanes';
import 'splitpanes/dist/splitpanes.css';
import { onMounted, ref, reactive } from 'vue';
import { useFs } from '@fast-crud/fast-crud';
import { createCrudOptions } from './crud';
import XEUtils from 'xe-utils';
import * as api from './api';
import type Node from 'element-plus/es/components/tree/src/model/node';
import type { AllowDropType, NodeDropType } from 'element-plus/es/components/tree/src/tree.type';

const { crudBinding, crudRef, crudExpose, treeData, parent } = useFs({ createCrudOptions });
const tree = ref();
const defaultProps = reactive({
	children: 'children',
	label: 'name',
});

// 添加
const add = (event, node, row) => {
	event.stopPropagation(); // 阻止事件冒泡
	parent.value = row.id;
	crudExpose.openAdd({ index: undefined });
};
// 编辑
const edit = (event, node, data) => {
	event.stopPropagation(); // 阻止事件冒泡
	const row = XEUtils.clone(data, true);
	delete row.children;
	crudExpose.openEdit({ row });
};
// 删除
const remove = (event, node, row) => {
	event.stopPropagation();
	crudExpose.doRemove({ row });
};
// 选中节点
const nodeClick = async (row) => {
	parent.value = row.id;
	crudExpose.doSearch({ form: { parent: row.id } });
};
            
// 拖拽事件
const handleDrop = (draggingNode: Node, dropNode: Node, dropType: NodeDropType) => {
	const obj = {
		drag: draggingNode.data.id,
		drop_type: dropType,
		drop: dropNode.data.id,
	};
	api.UpdateSort(obj).then(() => {
		crudExpose.doRefresh();
	});
};

// 拖拽时判定目标节点能否成为拖动目标位置
const allowDrop = (draggingNode: Node, dropNode: Node, type: AllowDropType) => {
	return dropNode.data.id;
};
// 判断节点能否被拖拽 如果返回 false ,节点不能被拖动
const allowDrag = (draggingNode: Node) => {
	return draggingNode.data.id;
};

// 页面打开后获取列表数据
onMounted(() => {
	crudExpose.doRefresh();
});
</script>

<style lang="scss" scoped>
.leftDiv {
	padding: 10px;
	height: 100%;
	background-color: #fff;
	overflow-y: auto;
}

.rightDiv {
	height: 100%;
	background-color: #fff;
}

.custom-tree-node {
	flex: 1;
	display: flex;
	align-items: center;
	justify-content: space-between;
	font-size: 14px;

	.button {
		display: none;
	}
}

.custom-tree-node:hover .button {
	display: block;
}
</style>