说明
框架使用的是 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>