1.功能描述
项目上需要一个级联选择的面板组件,而不是级联下拉选择,思前想后,直接使用第三方组件库可能无法更好的在项目上扩展功能,所以决定,定制化手写一个。
具体功能:初始化页面时保持第一组数据节点全部展开;鼠标单击展开下级节点;鼠标双击选中节点;并在下方显示选中节点的所有父级节点的面包屑,并可点击跳转到对应的父级节点;另外,面板可展开收起。
1.效果图
2.代码实现
(1)cascader/cascader-item.vue
<template>
<div class="cascader-item">
<!--首先渲染出级联组件的最左边部分-->
<div class="content-left" :style="{ width: width, height: height }">
<div class="title-item">
<!--{{ nzhLevel }}级空间-->
<span>{{ $t(`space.levelSpace`, { number: nzhLevel }) }}</span>
<!-- <link-button @click="goToSpace">查看列表</link-button> -->
</div>
<div class="list-box">
<div
:class="[
'label-item',
{
active: item.nodeCode == openItems?.[level]?.nodeCode,
select: item.nodeCode == selectCode
}
]"
v-for="item in options"
:key="item.nodeCode"
>
<span class="text" @click="select(item)" @dblclick="dblSelect(item)">
<Tooltip
:show-arrow="false"
effect="light"
placement="left"
popperClass="map-tooltip"
:content="item.nodeName"
:disabled="item.nodeName.replace(/[^\x00-\xff]/g, '01').length < 22"
>
{{ item.nodeName }}
</Tooltip>
</span>
<!--svg图标-->
<TowardsTheRight class="icon-right" v-if="item.children?.length" />
</div>
</div>
</div>
<!--点击左边中的某个选项后,lists才会有值才会渲染右边部分,同样渲染右边部分的时候,也是先渲染左边部分-->
<div class="content-right" v-if="lists?.length">
<CascaderItem
:options="lists"
:openItems="openItems"
:level="level + 1"
:selectCode="selectCode"
@changeItem="change"
@selectItem="changeSelect"
@changeEnter="changeEnterHandle"
/>
</div>
</div>
</template>
<script>
import { defineComponent, computed, ref, watch } from "vue";
import { Tooltip } from "第三方组件库";
import { TowardsTheRight } from "第三方组件库";
import { useRoute, useRouter } from "vue-router";
import nzhcn from "nzh/cn";
import i18n from "@/i18n";
import { ZH_CN } from "@/constant";
export default defineComponent({
name: "CascaderItem",
components: {
TowardsTheRight,
Tooltip
},
props: {
width: String,
height: String,
options: {
type: Array,
default: () => []
},
level: {
type: Number,
default: 0
},
openItems: {
type: Array,
default: () => []
},
selectCode: String
},
emits: ["changeItem", "checkItem", "changeEnter", "selectItem"],
setup(props, { emit }) {
const route = useRoute();
const router = useRouter();
const { locale } = i18n.global;
const nzhLevel = computed(() =>
locale == ZH_CN ? nzhcn.encodeS(props.level + 1) : props.level + 1
);
const lists = computed(() => {
// 根据内容value的变化显示列表,根据当前点击位置对应的level去获取要显示的列表
return props.openItems?.[props.level]?.children || [];
});
// 处理CascaderItem组件内左侧列点击事件,item为当前点击的对象
const select = (item) => {
// 向上一级发射一个change事件,通知上层进行修改,并将当前点击的层级level和item传递过去
emit("changeItem", { level: props.level, item: item });
};
const change = (newValue) => {
// 向顶层传递数据改变信息
emit("changeItem", newValue);
};
const dblSelect = (item) => {
emit("selectItem", { level: props.level, item: item });
};
const changeSelect = (newValue) => {
emit("selectItem", newValue);
};
const changeCheck = (item) => {
emit("checkItem", { level: props.level, item: item });
};
const checkHandle = (newValue) => {
// props.openItems[props.level].indeterminate = newValue.item.checked;
emit("checkItem", props.openItems[props.level]);
};
//进入选中的空间页面
const goToSpace = () => {
let query = { projectId: route.query.projectId };
if (props.level > 0) query.nodeCode = props.openItems[props.level - 1].nodeCode;
router.push({ path: "/space/child-space", query });
emit("changeEnter");
};
const changeEnterHandle = () => {
emit("changeEnter");
};
return {
lists,
nzhLevel,
select,
change,
changeCheck,
checkHandle,
goToSpace,
changeEnterHandle,
dblSelect,
changeSelect
};
}
});
</script>
<style lang="less" scoped>
.cascader-item {
display: flex;
height: 100%;
user-select: none;
}
.content-left {
width: 211px;
height: 100%;
border-right: 1px solid #e5e6eb;
.title-item {
display: flex;
align-items: center;
justify-content: space-between;
width: 211px;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #e5e6eb;
user-select: none;
}
.list-box {
height: calc(100% - 48px);
overflow-y: overlay;
&::-webkit-scrollbar-thumb {
background: transparent;
}
&:hover {
&::-webkit-scrollbar-thumb {
background: #c9cdd4;
}
}
}
.label-item {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px;
margin-bottom: 0;
padding: 0 12px;
border-radius: 4px;
cursor: pointer;
/deep/.ori-tooltip .el-tooltip__trigger {
user-select: none;
}
.icon-right {
color: #86909c;
font-size: 16px;
}
.text {
width: 100%;
height: 32px;
overflow: hidden;
line-height: 32px;
white-space: nowrap;
text-overflow: ellipsis;
}
&.active,
&:hover {
background: #f2f3f5;
}
&.select {
font-weight: 500;
background: #e8edff;
/deep/.el-tooltip .el-tooltip__trigger {
color: #5e66f2;
}
}
&.active {
.icon-right {
color: #1d2129;
}
}
/deep/.el-checkbox {
margin-right: 0;
.el-checkbox {
height: 0;
}
}
}
}
</style>
(2)cascader/index.vue
<template>
<CascaderItem
:width="width"
:height="height"
:options="options"
:openItems="openItems"
:level="0"
:selectCode="selectCode"
@changeItem="change"
@selectItem="changdeSelect"
@changeEnter="changeEnter"
/>
<!-- @checkItem="checkHandle" -->
<!--传入level从0开始-->
</template>
<script>
import { computed, defineComponent, ref, watch } from "vue";
import CascaderItem from "./cascader-item";
import { useStore } from "vuex";
import { getParents } from "@/utils/tool";
export default defineComponent({
props: {
width: {
type: String,
default: "211px"
},
height: {
type: String,
default: "100%"
},
options: {
type: Array,
default: () => []
},
openList: {
type: Array,
default: () => []
},
selectList: {
type: Array,
default: () => []
},
currCode: String
},
components: {
CascaderItem
},
emits: ["changeEnter", "update:openList", "update:selectList"],
setup(props, { emit }) {
const store = useStore();
// const openItems = ref(store.state.space.openItems);
const openItems = computed({
get: () => props.openList,
set: (v) => emit("update:openList", v)
});
const selectItems = computed({
get: () => props.selectList,
set: (v) => emit("update:selectList", v)
});
const selectCode = ref("");
const change = (newValue) => {
openItems.value?.splice(newValue.level, 1, newValue.item); // 替换当前点击位置信息
openItems.value?.splice(newValue.level + 1); // 删除当前点击位置之后的数据
};
const changdeSelect = (newValue) => {
if (selectItems.value.length == openItems.value?.length) {
let temp = false;
selectItems.value.map((item, index) => {
if (item.nodeCode != openItems.value?.[index].nodeCode) temp = true;
});
if (!temp) return;
}
selectItems.value = [...(openItems.value || [])];
selectCode.value = newValue.item.nodeCode;
};
const changeEnter = () => {
emit("changeEnter");
// store.commit("space/setSelectedItems", openItems.value);
};
const checkHandle = (newValue) => {
// openItems.value[0].indeterminate = true;
};
//默认取第一个
const getFirstItem = (item, result) => {
result.push(item);
if (item.children && item.children.length > 0) {
getFirstItem(item.children[0], result);
}
return result;
};
watch(
() => props.options,
(newVal, oldVal) => {
openItems.value = props.currCode
? getParents(newVal || [], props.currCode)
: newVal?.length
? getFirstItem(newVal[0], [])
: [];
},
{
immediate: true
}
);
watch(
() => props.currCode,
(newVal) => {
selectCode.value = newVal;
},
{
immediate: true
}
);
watch(selectItems, (newVal) => {
if (!newVal || newVal.length == 0) selectCode.value = "";
});
return {
change,
openItems,
selectCode,
checkHandle,
changeEnter,
changdeSelect
};
}
});
</script>
(3)utils/tool.js
/**
* 根据树子节点获取所有父节点
* @param {*} list
* @param {*} code
* @param {*} result
* @returns array/booolean
*/
export const getParents = (list, code, name = "nodeCode", result = []) => {
let arr = Array.from(result);
for (let item of list) {
arr.push(item);
if (item[name] === code) {
return arr;
}
if (item.children && item.children.length) {
let tempRes = getParents(item.children, code, name, arr);
if (tempRes) return tempRes;
}
arr.pop();
}
return null;
};
(4)使用
<template>
<div class="space-filter">
<div :class="['space-filter__open', { open: spaceShow }]">
<div class="space-list" v-show="spaceShow">
<Cascader
:options="spaceList"
v-model:openList="openSpaces"
v-model:selectList="currSpaces"
:currCode="currCode"
/>
</div>
</div>
<div :class="['space-filter__show', 'open']" v-show="spaceShow">
<div v-if="currSpaces?.length" class="flex-ac">
<Breadcrumb>
<BreadcrumbItem
v-for="(item, c) in currSpaces"
:key="item.nodeCode"
@click="changeCurrSpaces(c)"
>{{ item.nodeName }}</BreadcrumbItem
>
</Breadcrumb>
<!--清除-->
<link-button class="mr-lt-16" @click="clearSpace">{{ $t(`operate.clear`) }}</link-button>
</div>
<div class="flex-ac" v-else>
<PromptFill -fill style="margin-right: 4px; color: #86909c; font-size: 16px" />
<!--鼠标左键双击名称选中,单击展开子集--->
<span>{{ $t(`space.selectSpaceDesc`) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, watch, computed } from "vue";
import { Breadcrumb } from "第三方组件库";
import {PromptFill } from "第三方组件库图标库";
import Cascader from "./cascader";
import { useRouter, useRoute,onBeforeRouteUpdate} from "vue-router";
import { getParents } from "@/utils/tool";
import i18n from "@/i18n";
const { locale } = i18n.global;
import { ZH_CN } from "@/constant";
const BreadcrumbItem = Breadcrumb.Item;
const props = defineProps({
spaceList: {
type: Array
},
toggle: Boolean,
nodeCode: String
});
const route = useRoute();
const router = useRouter();
//当前展开的节点
const openSpaces = ref([]);
//当前选中节点的nodeCode(唯一标识)
const currCode = ref("");
//当前选中的节点(包含父节点)例:[父节点,父节点,当前选中节点]
const currSpaces = ref([]);
//面板是否展开收起
const spaceShow = computed({
get: () => props.toggle,
set: (v) => emits("update:toggle", v)
});
currCode.value = route.query.nodeCode || "";
currSpaces.value = getParents(props.spaceList || [], currCode.value)
? getParents(props.spaceList || [], currCode.value)
: [];
//清除选中
const clearSpace = () => {
currSpaces.value = [];
openSpaces.value = getFirstItem(props.spaceList[0], []);
emits("clear");
};
const getFirstItem = (item, result) => {
if (!item) return result;
result.push(item);
if (item.children && item.children.length > 0) {
getFirstItem(item.children[0], result);
}
return result;
};
const changeCurrSpaces = (index) => {
let temp = [...currSpaces.value];
temp.splice(index + 1, currSpaces.value.length - index - 1);
currSpaces.value = temp;
};
watch(currSpaces, (newVal) => {
let nodeCode = newVal?.length ? newVal[newVal.length - 1].nodeCode : "";
let query = nodeCode ? { nodeCode } : {};
router.push({
path: "/space",
query
});
emits("changeCode", newVal?.length ? newVal[newVal.length - 1].nodeCode : "");
});
//如果选中后跳转到新的路由页面,则不需要以下处理
onBeforeRouteUpdate((to) => {
currCode.value = to.query.nodeCode || "";
});
</script>
<style lang="less" scoped>
.space-filter {
&__show {
display: flex;
align-items: center;
justify-content: space-between;
padding: 2px 12px;
padding: 6px 12px;
color: #4e5969;
line-height: 22px;
border: 1px solid #e5e6eb;
user-select: none;
&.open {
border-top: none;
}
}
&__open {
width: 100%;
height: 0;
transition: all 0.3s;
&.open {
height: 264px;
margin-top: 16px;
}
.space-list {
// padding-bottom: 8px;
height: 100%;
overflow: overlay hidden;
border: 1px solid #e5e6eb;
transition: all 0.3s;
&::-webkit-scrollbar-thumb {
background: transparent;
}
&:hover {
&::-webkit-scrollbar-thumb {
background: #c9cdd4;
}
}
}
}
}
</style>
(5)spaceList:
// spaceList的type为Array<spaceItem>,spaceItem数据结构:
const interface spaceItem = {
"nodeCode": String,
"nodeName": String,
"layer": Number,
"nodeType": Number,
"spaceType": Number,
"sub": Number,
"fullPath": String,
"fullPathName": String,
"area": String,
"nextNum": Number,
"labels": Array,
"spaceCode": String,
"children": Array<spaceItem>
}