需求:
需要树形来回穿梭, 并且子节点可以在父节点内调换位置. 父节点可以上下箭头排序(没做)
父组件
<template>
<div class="treeTransfer-box">
<TreeTransfer ref="treeTransfer" :nodeKey="'id'" :fromData="menuList" :toData="ruleForm.menuIds" :defaultProps="transferProps" :leftTit="'已选检测点'" :rightTit="'未选检测点'" @checkVal="checkVal" :BaseFormdata="BaseFormdata" />
</div>
</template>
<script lang="ts" setup>
import { defineAsyncComponent, ref, watch, nextTick, onMounted } from 'vue';
import TreeTransfer from './components/TreeTransfer.vue';
const treeTransfer = ref();
const menuList = ref([
{
id: 1,
label: '出厂水质检测点',
children: [
{ id: '11', label: '二水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '12', label: '三水厂出厂水质检测点', labelTypeId: 2, children: [], number: 1 },
{ id: '13', label: '四水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
{
id: 2,
label: '管网(末梢)水质检测点',
children: [
{ id: '21', label: '管网二水厂出厂水质检测点', labelTypeId: 2, children: [], number: 3 },
{ id: '22', label: '管网三水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '23', label: '管网四水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
{
id: 3,
label: '二水厂1号井水质检测点',
children: [
{ id: '31', label: '二水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '32', label: '三水厂出厂水质检测点', labelTypeId: 2, children: [], number: 3 },
{ id: '33', label: '四水厂出厂水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
{
id: 4,
label: '二水厂1号井水质检测点',
children: [
{ id: '41', label: '二水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '42', label: '三水厂1号井水质检测点', labelTypeId: 2, children: [], number: 2 },
{ id: '43', label: '四水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
{
id: 5,
label: '二水厂1号井水质检测点',
children: [
{ id: '51', label: '二水厂1号井水质检测点', labelTypeId: 2, children: [], number: 1 },
{ id: '52', label: '三水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '53', label: '四水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
{
id: 6,
label: '二水厂1号井水质检测点',
children: [
{ id: '61', label: '二水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '62', label: '三水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
{ id: '63', label: '四水厂1号井水质检测点', labelTypeId: 2, children: [], number: 0 },
],
disabled: false,
},
]);
const detailsList = [
{ key: '任务周期', value: '日检' },
{ key: '期检次数 (已检/需检)', value: '0/1次' },
{ key: '需检指标', value: '10项' },
{ key: '闭单延长时间', value: '2天' },
{ key: '备注', value: '检测对象包含4个水厂的出厂水质、以及从全部站点中抽出来的10个站点的水质' },
{ key: '采样时间', value: '2024.06.01' },
];
const ruleForm = ref({
menuIds: [],
});
const transferProps = ref({ children: 'children', label: 'label', disabled: 'disabled', number: 'number' });
const BaseFormdata = ref<any>({});
const BaseData = ref<{ visible: boolean; title: string }>({
visible: false,
title: '',
});
watch(
BaseData,
(val) => {
if (val.visible) {
nextTick(() => {
treeTransfer.value.initData();
});
}
},
{
deep: true,
}
);
onMounted(() => {
Init('新增', {});
});
// 初始化
const Init = (title: string, data: any) => {
BaseFormdata.value = data ? JSON.parse(JSON.stringify(data)) : {};
console.log('BaseFormdata.value', BaseFormdata.value);
BaseData.value.title = title;
BaseData.value.visible = true;
};
const checkVal = (val: any) => {
const arr = [];
for (const i in val) {
arr.push(val[i].id);
}
console.log('arrr', arr);
console.log('ruleForm', ruleForm);
ruleForm.menuIds = arr;
};
</script>
<style lang="scss" scoped>
.treeTransfer-box {
width: 50vw;
min-height: 40vh;
}
</style>
子组件
<template>
<div class='tree-transfer'>
<!-- 左侧框 -->
<div class="left-tree ">
<div class="header">
<div class="header-left">
<el-checkbox v-model="checkedLeft" :disabled="leftData.length < 1" label="" size="large" @change="leftAllCheck" />
<div class="tree-tit">{{leftTit || '左侧栏'}}</div>
</div>
<span class="header-right">
0/10
</span>
</div>
<div class="content">
<!-- 搜索框 -->
<div class="seacrh-box">
<el-input v-model.trem="leftFilterText" placeholder="请输入" clearable @clear="onSearchLeft" @keyup.enter="onSearchLeft" @change="onSearchLeft"></el-input>
<el-icon>
<Search />
</el-icon>
</div>
<!-- 数据区域 -->
<div class="tree-box">
<el-tree ref="treeRefL" v-if="reLoad" :data="leftData" show-checkbox default-expand-all :node-key="nodeKey" highlight-current :props="defaultProps" :allow-drop="allowDrop" draggable :allow-drag="allowDrag" :filter-node-method="filterNode">
<template #default="scope">
<div class=" el-tree-node">
<div class=" el-tree-node__content">
<div class="custom-tree-node">
<div class="custom-tree-node-text-box" style="margin-left: 10px;">
<span class="custom-tree-node-title">
{{ `${scope.data.label} `}}
</span>
<div v-if="scope.node.level == 1" class="custom-tree-node-icon" style="text-align: right;">
<span>↑</span>
<span>↓</span>
<span class="line">|</span>
<span class="number">2</span>
</div>
<div v-if="scope.node.level == 2" class="custom-right">
{{ scope.node.number}}
<div v-if="props.BaseFormdata.date =='month'">
当年理化<span :style="{color:scope.data.number > 0?'#1FC26B':'#F23061'}">{{scope.data.number}}</span>次
</div>
<div>
<img src="../../../../assets/icon/list.png" width="20" alt="">
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</el-tree>
</div>
</div>
</div>
<div class="btn-div">
<el-button :icon="ArrowLeftBold" type="info" :disabled="disabled" @click="toLeft()" />
<el-button :icon="ArrowRightBold" type="info" :disabled="disabled" @click="toRight()" />
</div>
<div class="right-tree">
<div class="header">
<div class="header-left">
<el-checkbox v-model="checkedRight" :disabled="rightData.length < 1" label="" size="large" @change="rightAllCheck" />
<div class="tree-tit">{{rightTit || '右侧栏'}}</div>
</div>
<span class="header-right">
0/10
</span>
</div>
<div class="content">
<!-- 搜索框 -->
<div class="seacrh-box">
<el-input v-model.trem="rightFilterText" placeholder="请输入" clearable @clear="onSearchRight" @keyup.enter="onSearchRight" @change="onSearchRight"></el-input>
<el-icon>
<Search />
</el-icon>
</div>
<!-- 数据区域 -->
<div class="tree-box">
<el-tree ref="treeRefR" v-if="reLoad" :data="rightData" show-checkbox default-expand-all :node-key="nodeKey" highlight-current :props="defaultProps" :filter-node-method="filterNode" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, defineExpose, watch } from 'vue';
import { ArrowLeftBold, ArrowRightBold, Back, Search } from '@element-plus/icons-vue';
import { array } from 'js-md5';
import lodash from 'lodash';
const props = defineProps({
nodeKey: String,
fromData: Array,
toData: Array,
defaultProps: {},
leftTit: String,
rightTit: String,
disabled: {
type: Boolean,
default: false,
},
BaseFormdata: Object,
});
//定义emit
const emit = defineEmits(['checkVal']);
const treeRefL = ref([]);
const treeRefR = ref([]);
const leftData = ref([]);
const rightData = ref([]);
const reLoad = ref(true);
//右侧数据
const toData = ref([]);
// 右侧需要移除的数据
const removeData = ref([]);
const checkedLeft = ref(false);
const checkedRight = ref(false);
const leftFilterText = ref<string>('');
const rightFilterText = ref<string>('');
watch(rightFilterText, (val) => {
treeRefR.value!.filter(val);
});
watch(leftFilterText, (val) => {
treeRefL.value!.filter(val);
});
/**
* 清空数据
*/
const clearData = () => {
toData.value = [];
};
/**
* 初始化数据
*/
const initData = () => {
reLoad.value = true;
const originalLeft = JSON.parse(JSON.stringify(props.fromData));
const originalRight = JSON.parse(JSON.stringify(props.fromData));
if (props.toData.length > 0) {
leftData.value = sortData(originalLeft, props.toData, 'left');
rightData.value = sortData(originalRight, props.toData, 'right');
} else {
leftData.value = originalLeft;
rightData.value = [];
}
};
//方法
//去右边
const toRight = () => {
// 将勾选中的数据保存到toData中
const checkNodes = treeRefL.value.getCheckedNodes(false, false);
const newArr = toData.value.concat(checkNodes);
const obj = {};
const peon = newArr.reduce((cur, next) => {
obj[next[props.nodeKey]] ? '' : (obj[next[props.nodeKey]] = true && cur.push(next));
return cur;
}, []); //设置cur默认类型为数组,并且初始值为空的数组
toData.value = peon;
reLoad.value = false;
const originalLeft = JSON.parse(JSON.stringify(props.fromData));
const originalRight = JSON.parse(JSON.stringify(props.fromData));
// 抽离出选中数据中的id
const ids = extractId(toData.value);
// 重新整理两侧树中数据
leftData.value = sortData(originalLeft, ids, 'left');
rightData.value = sortData(originalRight, ids, 'right');
nextTick(() => {
reLoad.value = true;
});
checkVal();
};
//去左边
const toLeft = () => {
// 将勾选中的数据保存到toData中
const checkNodes = treeRefR.value.getCheckedNodes(false, false);
const newArr = removeData.value.concat(checkNodes);
const obj = {};
const peon = newArr.reduce((cur, next) => {
obj[next[props.nodeKey]] ? '' : (obj[next[props.nodeKey]] = true && cur.push(next));
return cur;
}, []); //设置cur默认类型为数组,并且初始值为空的数组
const dataNeedRemove = peon;
reLoad.value = false;
const originalLeft = JSON.parse(JSON.stringify(props.fromData));
const originalRight = JSON.parse(JSON.stringify(props.fromData));
// 抽离出选中数据中的id
const idsNeedRemove = extractId(dataNeedRemove);
// 删除相同id
const oldData = removeId(toData.value, idsNeedRemove);
toData.value = oldData;
// 右侧列表需要保留的数据的id
const ids = extractId(oldData);
// 重新整理两侧树中数据
leftData.value = sortData(originalLeft, ids, 'left');
rightData.value = sortData(originalRight, ids, 'right');
nextTick(() => {
reLoad.value = true;
});
checkVal();
};
/**
* 将tree中的整理进行整理,判断数据是否再tree中显示
* @param data tree数据
* @param condition 被选中的数据
* @param leftRight 整理左侧tree中的数据还是整理右侧tree中的数据
*/
const sortData = (data: any, condition: Array<string>, leftRight: string) => {
if (leftRight === 'left') {
const result = [];
for (const item of data) {
// 判断item的id是否在condition中,如果不在,说明不需要删除
if (!condition.includes(item.id)) {
// 如果item有children属性,递归调用本函数,传入item的children和condition
if (item.children) {
item.children = sortData(item.children, condition, leftRight);
}
// 如果item的children为空数组,删除item的children属性
if (item.children && item.children.length === 0) {
delete item.children;
}
result.push(item);
} else {
// 否则,判断item是否有children属性
if (item.children) {
const subResult = sortData(item.children, condition, leftRight);
// 如果返回的结果数组不为空,说明有符合条件的子数据
if (subResult.length > 0) {
// 将item的children属性更新为返回的结果数组
item.children = subResult;
result.push(item);
}
}
}
}
return result;
} else {
const result = [];
for (const item of data) {
// 如果item的id在condition中,说明该数据需要保留
if (condition.includes(item.id)) {
if (item.children) {
item.children = sortData(item.children, condition, leftRight);
}
// 如果item的children为空数组,删除item的children属性
if (item.children && item.children.length === 0) {
delete item.children;
}
result.push(item);
} else {
// 否则,判断item是否有children属性
if (item.children) {
const subResult = sortData(item.children, condition, leftRight);
// 如果返回的结果数组不为空,说明有符合条件的子数据
if (subResult.length > 0) {
// 将item的children属性更新为返回的结果数组
item.children = subResult;
result.push(item);
}
}
}
}
return result;
}
};
/**
* 如果新数组中的id再旧数组中存在则删除原始数组中的id
* @param oldIds 原始id
* @param newIds 新id
*/
const removeId = (data: any, newIds: Array<string>) => {
const ids = [];
for (const item of data) {
if (!newIds.includes(item.id)) {
ids.push(item);
}
}
return ids;
};
/**
* 将id从备选中的数据取出
* @param arr tree中被选中的数据
*/
const extractId = (arr: any) => {
const newArr = [];
for (const i in arr) {
newArr.push(arr[i].id);
}
return newArr;
};
//返回父组件
const checkVal = () => {
emit('checkVal', toData.value);
};
// 判断节点是否可拖拽
const allowDrag = (draggingNode) => {
return draggingNode.data.labelTypeId; //唯一键,最外层父节点无labelTypeId属性,则不可拓展
};
// 拖拽时判定目标节点能否被放置
// 'prev'、'inner' 和 'next',分前、插入、后
const allowDrop = (moveNode, inNode, type: string) => {
// allow-drop属性是用来限制树节点拖拽后是否可以放置在当前位置,属性值为true时可以,为false时不可以。
// 1.子节点禁止外托
// 2.禁止拖进有子节点的层级
// 3.没有设置一级节点判断 不可拖拽在allowDrag设置
// 二级拖动到二级
if (moveNode.level == 2 && inNode.level == 2 && moveNode.parent.id == inNode.parent.id) {
// 四种情况
if (moveNode.nextSibling == undefined) {
return type == 'prev';
} else if (inNode.nextSibling == undefined) {
return type == 'next';
} else if (moveNode.nextSibling.id !== inNode.id) {
return type == 'prev';
} else {
return type == 'next';
}
}
};
//左侧头部全选
const leftAllCheck = () => {
const leftTree = treeRefL.value;
if (checkedLeft.value) {
leftTree?.setCheckedNodes(leftData.value);
checkedLeft.value = true;
} else {
leftTree?.setCheckedNodes([]);
checkedLeft.value = false;
}
};
//右侧头部全选
const rightAllCheck = () => {
const rightTree = treeRefR.value;
if (checkedRight.value) {
rightTree?.setCheckedNodes(rightData.value);
checkedRight.value = true;
} else {
rightTree?.setCheckedNodes([]);
checkedRight.value = false;
}
};
// 左侧搜素
const onSearchLeft = () => {
treeRefL.value!.filter(leftFilterText.value);
};
// 右侧搜素
const onSearchRight = () => {
treeRefR.value!.filter(rightFilterText.value);
};
//tree 过滤
const filterNode = (value: string, data: any) => {
if (!value) return true;
return data.label.includes(value);
};
defineExpose({
clearData,
initData,
});
</script>
<style lang="scss" scoped>
.tree-transfer {
width: 100%;
height: 100%;
display: flex;
justify-content: space-between;
.left-tree,
.right-tree {
flex-grow: 1;
width: calc((100% - 60px) / 2);
border-radius: 4px;
box-sizing: border-box;
border: 1px solid #d1d9e5;
.header {
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
background: #f0f2f5;
padding: 0 12px 0 14px;
.header-left {
display: flex;
align-items: center;
width: 80%;
.tree-tit {
font-family: MiSans;
font-size: 14px;
font-weight: bold;
line-height: 14px;
letter-spacing: 0em;
font-variation-settings: 'opsz' auto;
color: #01193d;
}
}
}
.content {
.seacrh-box {
display: flex;
align-items: center;
height: 56px;
background: #f0f2f5;
padding: 12px;
box-sizing: border-box;
border: 1px solid #d1d9e5;
.el-input {
height: 32px;
border-radius: 4px;
background: #ffffff;
box-sizing: border-box;
border: 1px solid #d1d9e5;
margin-right: 7px;
}
.el-icon {
cursor: pointer;
}
}
.tree-box {
overflow: auto;
min-height: 300px;
height: 400px;
border: 1px solid #ddd;
border-radius: 4px;
.item {
padding: 0 10px;
font-size: 14px;
line-height: 26px;
cursor: pointer;
&.active {
background: #b9d7fa;
}
}
.item-checkbox {
height: 26px;
padding: 0 10px;
font-size: 14px;
line-height: 26px;
& > .el-checkbox {
height: 26px;
}
}
.el-tree-node {
width: 100%;
.el-tree-node > .el-tree-node__content {
height: 28px;
width: 100%;
.custom-tree-node {
width: 100%;
.custom-tree-node-text-box {
display: flex;
justify-content: space-between;
.custom-tree-node-title {
}
.custom-tree-node-icon {
span {
width: 16px;
height: 16px;
opacity: 0.4;
margin-right: 4px;
}
span.line {
margin-left: 8px;
}
span.number {
margin-left: 17px;
margin-right: 12px;
}
}
.custom-right {
display: flex;
div:first-child {
margin-right: 5px;
}
}
}
}
}
}
.el-tree {
.el-tree-node.is-expanded.is-focusable > .el-tree-node__content {
background: #f0f2f5;
}
}
}
}
}
.btn-div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 64px;
.el-button {
width: 40px;
height: 40px;
border-radius: 4px;
color: #4e5766;
background: rgba(191, 200, 217, 0.2);
box-sizing: border-box;
border: 1px solid #bfc8d9;
margin-bottom: 8px;
}
.el-button + .el-button {
margin-left: 0;
}
}
.el-checkbox__input.is-disabled .el-checkbox__inner {
display: none;
}
}
.el-table .rowStyle {
background-color: #f8f8f8;
}
:deep(.el-tree--highlight-current .el-tree-node.is-current > .el-tree-node__content) {
padding-left: 0px;
background: #f0f2f5;
}
:deep(.el-tree--highlight-current) {
.el-tree-node.is-expanded.is-focusable {
.el-tree-node__content {
background-color: #f0f2f5;
}
.el-tree-node__children {
.el-tree-node__content {
background-color: #fff;
}
}
}
}
</style>