Files
jobslink-user-clent/uni_modules/custom-tree-select/components/custom-tree-select/custom-tree-select.vue
2024-06-05 16:58:38 +08:00

984 lines
26 KiB
Vue

<template>
<view class="custom-tree-select-content">
<view
:class="['select-list', { disabled }, { active: selectList.length }]"
:style="boxStyle"
@click.stop="open"
>
<view class="left">
<view v-if="selectList.length" class="select-items">
<view
class="select-item"
v-for="item in selectedListBaseinfo"
:key="item[dataValue]"
>
<view class="name">
<text>{{ pathMode ? item.path : item[dataLabel] }}</text>
</view>
<view
v-if="!disabled && !item.disabled"
class="close"
@click.stop="removeSelectedItem(item)"
>
<uni-icons type="closeempty" size="16" color="#999"></uni-icons>
</view>
</view>
</view>
<view v-else style="color: #6a6a6a" class="no-data">
<text style="#C0C4CB">{{ placeholder }}</text>
</view>
</view>
<view class="right">
<uni-icons
v-if="!selectList.length || !clearable"
type="bottom"
size="14"
color="#999"
></uni-icons>
<uni-icons
v-if="selectList.length && clearable"
type="clear"
size="24"
color="#c0c4cc"
@click.native.stop="clear"
></uni-icons>
</view>
</view>
<uni-popup
v-if="showPopup"
ref="popup"
:animation="animation"
:is-mask-click="isMaskClick"
:mask-background-color="maskBackgroundColor"
:background-color="backgroundColor"
:safe-area="safeArea"
type="bottom"
@change="change"
@maskClick="maskClick"
>
<view
class="popup-content"
:style="{ height: contentHeight || defaultContentHeight }"
>
<view class="title">
<view
v-if="mutiple && canSelectAll"
class="left"
@click.stop="handleSelectAll"
>
<text>{{ isSelectedAll ? '取消全选' : '全选' }}</text>
</view>
<view class="center">
<text>{{ placeholder }}</text>
</view>
<view
class="right"
:style="{ color: confirmTextColor }"
@click.stop="close"
>
<text>{{ confirmText }}</text>
</view>
</view>
<view v-if="search" class="search-box">
<uni-easyinput
:maxlength="-1"
prefixIcon="search"
placeholder="搜索"
v-model="searchStr"
confirm-type="search"
@confirm="handleSearch(false)"
@clear="handleSearch(true)"
>
</uni-easyinput>
<button
type="primary"
size="mini"
class="search-btn"
@click.stop="handleSearch(false)"
>
搜索
</button>
</view>
<view v-if="treeData.length" class="select-content">
<scroll-view
class="scroll-view-box"
:scroll-top="scrollTop"
scroll-y="true"
@touchmove.stop
>
<view v-if="!filterTreeData.length" class="no-data center">
<text>暂无数据</text>
</view>
<data-select-item
v-for="item in filterTreeData"
:key="item[dataValue]"
:node="item"
:dataLabel="dataLabel"
:dataValue="dataValue"
:dataChildren="dataChildren"
:choseParent="choseParent"
:border="border"
:linkage="linkage"
:load="load"
:lazyLoadChildren="lazyLoadChildren"
></data-select-item>
<view class="sentry" />
</scroll-view>
</view>
<view v-else class="no-data center">
<text>暂无数据</text>
</view>
</view>
</uni-popup>
</view>
</template>
<script>
const partCheckedSet = new Set()
import { paging } from './utils'
import dataSelectItem from './data-select-item.vue'
export default {
name: 'custom-tree-select',
components: {
dataSelectItem
},
model: {
prop: 'value',
event: 'input'
},
props: {
boxStyle: {
type: String,
default: ''
},
canSelectAll: {
type: Boolean,
default: false
},
safeArea: {
type: Boolean,
default: true
},
search: {
type: Boolean,
default: false
},
clearResetSearch: {
type: Boolean,
default: false
},
animation: {
type: Boolean,
default: true
},
'is-mask-click': {
type: Boolean,
default: true
},
'mask-background-color': {
type: String,
default: 'rgba(0,0,0,0.4)'
},
'background-color': {
type: String,
default: 'none'
},
'safe-area': {
type: Boolean,
default: true
},
choseParent: {
type: Boolean,
default: true
},
placeholder: {
type: String,
default: '请选择'
},
confirmText: {
type: String,
default: '完成'
},
confirmTextColor: {
type: String,
default: '#007aff'
},
contentHeight: {
type: String
},
disabledList: {
type: Array,
default: () => []
},
listData: {
type: Array,
default: () => []
},
dataLabel: {
type: String,
default: 'name'
},
dataValue: {
type: String,
default: 'id'
},
dataChildren: {
type: String,
default: 'children'
},
linkage: {
type: Boolean,
default: false
},
clearable: {
type: Boolean,
default: false
},
mutiple: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
},
showChildren: {
type: Boolean,
default: false
},
border: {
type: Boolean,
default: false
},
pathMode: {
type: Boolean,
default: false
},
pathHyphen: {
type: String,
default: '/'
},
load: {
type: Function,
default: function () {}
},
lazyLoadChildren: {
type: Boolean,
default: false
},
value: {
type: Array,
default: () => []
}
},
data() {
return {
defaultContentHeight: '500px',
treeData: [],
filterTreeData: [],
clearTimerList: [],
selectedListBaseinfo: [],
showPopup: false,
clickOpenTimer: null,
isSelectedAll: false,
scrollTop: 0,
searchStr: ''
}
},
computed: {
selectList() {
return this.value || []
}
},
watch: {
listData: {
deep: true,
immediate: true,
handler(newVal) {
if (newVal) {
partCheckedSet.clear()
this.treeData = this.initData(newVal)
if (this.value) {
this.changeStatus(this.treeData, this.value, true)
this.filterTreeData.length &&
this.changeStatus(this.filterTreeData, this.value)
}
if (this.showPopup) {
this.resetClearTimerList()
this.renderTree(this.treeData)
}
}
}
},
value: {
immediate: true,
handler(newVal) {
if (newVal) {
this.changeStatus(this.treeData, this.value, true)
this.filterTreeData.length &&
this.changeStatus(this.filterTreeData, this.value)
}
}
}
},
mounted() {
this.getContentHeight(uni.getSystemInfoSync())
},
methods: {
// 搜索完成返回顶部
goTop() {
this.scrollTop = 10
this.$nextTick(() => {
this.scrollTop = 0
})
},
// 获取对应数据
getReflectNode(node, arr) {
const array = [...arr]
while (array.length) {
const item = array.shift()
if (item[this.dataValue] === node[this.dataValue]) {
return item
}
if (item[this.dataChildren]?.length) {
array.push(...item[this.dataChildren])
}
}
return {}
},
getContentHeight({ screenHeight }) {
this.defaultContentHeight = `${Math.floor(screenHeight * 0.7)}px`
},
// 处理搜索
handleSearch(isClear = false) {
this.resetClearTimerList()
if (isClear) {
// 点击清空按钮并且设置清空按钮会重置搜索
if (this.clearResetSearch) {
this.renderTree(this.treeData)
}
} else {
this.renderTree(this.searchValue(this.searchStr, this.treeData))
}
this.goTop()
uni.hideKeyboard()
},
// 具体搜索方法
searchValue(str, arr) {
const res = []
arr.forEach((item) => {
if (item.visible) {
if (
item[this.dataLabel]
.toString()
.toLowerCase()
.indexOf(str.toLowerCase()) > -1
) {
res.push(item)
} else {
if (item[this.dataChildren]?.length) {
const data = this.searchValue(str, item[this.dataChildren])
if (data?.length) {
if (
str &&
!item.showChildren &&
item[this.dataChildren]?.length
) {
item.showChildren = true
}
res.push({
...item,
[this.dataChildren]: data
})
}
}
}
}
})
return res
},
// 懒加载
renderTree(arr) {
const pagingArr = paging(arr)
this.filterTreeData.splice(
0,
this.filterTreeData.length,
...(pagingArr?.[0] || [])
)
this.lazyRenderList(pagingArr, 1)
},
// 懒加载具体逻辑
lazyRenderList(arr, startIndex) {
for (let i = startIndex; i < arr.length; i++) {
let timer = null
timer = setTimeout(() => {
this.filterTreeData.push(...arr[i])
}, i * 500)
this.clearTimerList.push(() => clearTimeout(timer))
}
},
// 中断懒加载
resetClearTimerList() {
const list = [...this.clearTimerList]
this.clearTimerList = []
list.forEach((fn) => fn())
},
// 打开弹窗
open() {
// disaled模式下禁止打开弹窗
if (this.disabled) return
this.showPopup = true
this.$nextTick(() => {
this.$refs.popup.open()
this.renderTree(this.treeData)
})
},
// 关闭弹窗
close() {
this.$refs.popup.close()
},
// 弹窗状态变化 包括点击回显框和遮罩
change(data) {
if (data.show) {
uni.$on('custom-tree-select-node-click', this.handleNodeClick)
uni.$on('custom-tree-select-name-click', this.handleHideChildren)
uni.$on('custom-tree-select-load', this.handleLoadNode)
} else {
uni.$off('custom-tree-select-node-click', this.handleNodeClick)
uni.$off('custom-tree-select-name-click', this.handleHideChildren)
uni.$off('custom-tree-select-load', this.handleLoadNode)
this.resetClearTimerList()
this.searchStr = ''
if (this.animation) {
setTimeout(() => {
this.showPopup = false
}, 200)
} else {
this.showPopup = false
}
}
this.$emit('change', data)
},
// 点击遮罩
maskClick() {
this.$emit('maskClick')
},
// 初始化数据
initData(arr, parentVisible = undefined, pathArr = []) {
if (!Array.isArray(arr)) return []
const res = []
for (let i = 0; i < arr.length; i++) {
const curPathArr = [...pathArr, arr[i][this.dataLabel]]
const obj = {
...arr[i],
[this.dataLabel]: arr[i][this.dataLabel],
[this.dataValue]: arr[i][this.dataValue],
path: curPathArr.join(this.pathHyphen)
}
obj.checked = this.selectList.includes(arr[i][this.dataValue])
obj.disabled = false
if (
Boolean(arr[i].disabled) ||
this.disabledList.includes(obj[this.dataValue])
) {
obj.disabled = true
}
//半选
obj.partChecked = Boolean(
arr[i].partChecked === undefined ? false : arr[i].partChecked
)
obj.partChecked && obj.partCheckedSet.add(obj[this.dataValue])
!obj.partChecked && (this.isSelectedAll = false)
const parentVisibleState =
parentVisible === undefined ? true : parentVisible
const curVisibleState =
arr[i].visible === undefined ? true : Boolean(arr[i].visible)
if (parentVisibleState === curVisibleState) {
obj.visible = parentVisibleState
} else if (!parentVisibleState || !curVisibleState) {
obj.visible = false
} else {
obj.visible = true
}
obj.showChildren =
'showChildren' in arr[i] && arr[i].showChildren != undefined
? arr[i].showChildren
: this.showChildren
if (arr[i][this.dataChildren]?.length) {
const childrenVal = this.initData(
arr[i][this.dataChildren],
obj.visible,
curPathArr
)
obj[this.dataChildren] = childrenVal
if (
!obj.checked &&
childrenVal.some((item) => item.checked || item.partChecked)
) {
obj.partChecked = true
partCheckedSet.add(obj[this.dataValue])
}
}
res.push(obj)
}
return res
},
// 获取某个节点后面所有元素
getChildren(node) {
if (!node[this.dataChildren]?.length) return []
const res = node[this.dataChildren].reduce((pre, val) => {
if (val.visible) {
return [...pre, val]
}
return pre
}, [])
for (let i = 0; i < node[this.dataChildren].length; i++) {
res.push(...this.getChildren(node[this.dataChildren][i]))
}
return res
},
// 获取某个节点所有祖先元素
getParentNode(target, arr) {
let res = []
for (let i = 0; i < arr.length; i++) {
if (arr[i][this.dataValue] === target[this.dataValue]) {
return true
}
if (arr[i][this.dataChildren]?.length) {
const childRes = this.getParentNode(target, arr[i][this.dataChildren])
if (typeof childRes === 'boolean' && childRes) {
res = [arr[i]]
} else if (Array.isArray(childRes) && childRes.length) {
res = [...childRes, arr[i]]
}
}
}
return res
},
// 点击checkbox
handleNodeClick(data, status) {
const node = this.getReflectNode(data, this.treeData)
node.checked = typeof status === 'boolean' ? status : !node.checked
node.partChecked = false
partCheckedSet.delete(node[this.dataValue])
// 如果是单选不考虑其他情况
if (!this.mutiple) {
let emitData = []
if (node.checked) {
emitData = [node[this.dataValue]]
}
this.$emit('input', emitData)
} else {
// 多选情况
if (!this.linkage) {
// 不需要联动
let emitData = null
if (node.checked) {
emitData = Array.from(
new Set([...this.selectList, node[this.dataValue]])
)
} else {
emitData = this.selectList.filter(
(id) => id !== node[this.dataValue]
)
}
this.$emit('input', emitData)
} else {
// 需要联动
let emitData = [...this.selectList]
const parentNodes = this.getParentNode(node, this.treeData)
const childrenVal = this.getChildren(node).filter(
(item) => !item.disabled
)
if (node.checked) {
// 选中
emitData = Array.from(new Set([...emitData, node[this.dataValue]]))
if (childrenVal.length) {
emitData = Array.from(
new Set([
...emitData,
...childrenVal.map((item) => item[this.dataValue])
])
)
// 孩子节点全部选中并且清除半选状态
childrenVal.forEach((childNode) => {
childNode.partChecked = false
partCheckedSet.delete(childNode[this.dataValue])
})
}
if (parentNodes.length) {
let flag = false
// 有父元素 如果父元素下所有子元素全部选中,选中父元素
while (parentNodes.length) {
const item = parentNodes.shift()
if (!item.disabled) {
if (flag) {
// 前一个没选中并且为半选那么之后的全为半选
item.partChecked = true
partCheckedSet.add(item[this.dataValue])
} else {
const allChecked = item[this.dataChildren]
.filter((node) => node.visible && !node.disabled)
.every((node) => node.checked)
if (allChecked) {
item.checked = true
item.partChecked = false
partCheckedSet.delete(item[this.dataValue])
emitData = Array.from(
new Set([...emitData, item[this.dataValue]])
)
} else {
item.partChecked = true
partCheckedSet.add(item[this.dataValue])
flag = true
}
}
}
}
}
} else {
// 取消选中
emitData = emitData.filter((id) => id !== node[this.dataValue])
if (childrenVal.length) {
// 取消选中全部子节点
childrenVal.forEach((childNode) => {
emitData = emitData.filter(
(id) => id !== childNode[this.dataValue]
)
})
}
if (parentNodes.length) {
parentNodes.forEach((parentNode) => {
if (emitData.includes(parentNode[this.dataValue])) {
parentNode.checked = false
}
emitData = emitData.filter(
(id) => id !== parentNode[this.dataValue]
)
const hasChecked = parentNode[this.dataChildren]
.filter((node) => node.visible && !node.disabled)
.some((node) => node.checked || node.partChecked)
parentNode.partChecked = hasChecked
if (hasChecked) {
partCheckedSet.add(parentNode[this.dataValue])
} else {
partCheckedSet.delete(parentNode[this.dataValue])
}
})
}
}
this.$emit('input', emitData)
}
}
},
// 点击名称折叠或展开
handleHideChildren(node) {
const status = !node.showChildren
this.getReflectNode(node, this.treeData).showChildren = status
this.getReflectNode(node, this.filterTreeData).showChildren = status
},
// 根据 dataValue 找节点
changeStatus(list, ids, needEmit = false) {
const arr = [...list]
let flag = true
needEmit && (this.selectedListBaseinfo = [])
while (arr.length) {
const item = arr.shift()
if (ids.includes(item[this.dataValue])) {
this.$set(item, 'checked', true)
needEmit && this.selectedListBaseinfo.push(item)
// 数据被选中清除半选状态
item.partChecked = false
partCheckedSet.delete(item[this.dataValue])
} else {
this.$set(item, 'checked', false)
if (item.visible && !item.disabled) {
flag = false
}
if (partCheckedSet.has(item[this.dataValue])) {
this.$set(item, 'partChecked', true)
} else {
this.$set(item, 'partChecked', false)
}
}
if (item[this.dataChildren]?.length) {
arr.push(...item[this.dataChildren])
}
}
this.isSelectedAll = flag
needEmit && this.$emit('selectChange', [...this.selectedListBaseinfo])
},
// 移除选项
removeSelectedItem(node) {
this.isSelectedAll = false
if (this.linkage) {
this.handleNodeClick(node, false)
this.$emit('removeSelect', node)
} else {
const emitData = this.selectList.filter(
(item) => item !== node[this.dataValue]
)
this.$emit('removeSelect', node)
this.$emit('input', emitData)
}
},
// 全部选中
handleSelectAll() {
this.isSelectedAll = !this.isSelectedAll
if (this.isSelectedAll) {
if (!this.mutiple) {
uni.showToast({
title: '单选模式下不能全选',
icon: 'none',
duration: 1000
})
return
}
let emitData = []
this.treeData.forEach((item) => {
if (item.visible || (item.disabled && item.checked)) {
emitData = Array.from(new Set([...emitData, item[this.dataValue]]))
if (item[this.dataChildren]?.length) {
emitData = Array.from(
new Set([
...emitData,
...this.getChildren(item)
.filter(
(item) =>
!item.disabled || (item.disabled && item.checked)
)
.map((item) => item[this.dataValue])
])
)
}
}
})
this.$emit('input', emitData)
} else {
this.clear()
}
},
// 清空选项
clear() {
if (this.disabled) return
const emitData = []
partCheckedSet.clear()
this.selectedListBaseinfo.forEach((node) => {
if (node.visible && node.checked && node.disabled) {
emitData.push(node[this.dataValue])
}
})
this.$emit('input', emitData)
},
// 异步加载节点
handleLoadNode({ source, target }) {
// #ifdef MP-WEIXIN
const node = this.getReflectNode(source, this.treeData)
this.$set(
node,
this.dataChildren,
this.initData(
target,
source.visible,
source.path.split(this.pathHyphen)
)
)
this.$nextTick(() => {
this.handleHideChildren(node)
})
// #endif
// #ifndef MP-WEIXIN
this.$set(
source,
this.dataChildren,
this.initData(
target,
source.visible,
source.path.split(this.pathHyphen)
)
)
this.handleHideChildren(source)
// #endif
}
}
}
</script>
<style lang="scss" scoped>
$primary-color: #007aff;
$col-sm: 4px;
$col-base: 8px;
$col-lg: 12px;
$row-sm: 5px;
$row-base: 10px;
$row-lg: 15px;
$radius-sm: 3px;
$radius-base: 6px;
.custom-tree-select-content {
.select-list {
padding-left: $row-base;
min-height: 35px;
border: 1px solid #e5e5e5;
border-radius: $radius-sm;
display: flex;
justify-content: space-between;
align-items: center;
&.active {
padding: calc(#{$col-sm} / 2) 0 calc(#{$col-sm} / 2) $row-base;
}
.left {
flex: 1;
.select-items {
display: flex;
flex-wrap: wrap;
}
.select-item {
margin: $col-sm $row-base $col-sm 0;
padding: $col-sm $row-sm;
max-width: auto;
height: auto;
background-color: #eaeaea;
border-radius: $radius-sm;
color: #333;
display: flex;
align-items: center;
.name {
flex: 1;
padding-right: $row-base;
font-size: 14px;
}
.close {
width: 18px;
height: 18px;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
}
}
}
.right {
margin-right: $row-sm;
display: flex;
justify-content: flex-end;
align-items: center;
}
&.disabled {
background-color: #f5f7fa;
.left {
.select-item {
.name {
padding: 0;
}
}
}
}
}
.popup-content {
flex: 1;
background-color: #fff;
border-top-left-radius: 20px;
border-top-right-radius: 20px;
display: flex;
flex-direction: column;
.title {
padding: $col-base 3rem;
border-bottom: 1px solid $uni-border-color;
font-size: 14px;
display: flex;
justify-content: space-between;
position: relative;
.left {
position: absolute;
left: 10px;
}
.center {
flex: 1;
text-align: center;
}
.right {
position: absolute;
right: 10px;
}
}
.search-box {
margin: $col-base $row-base 0;
background-color: #fff;
display: flex;
align-items: center;
.search-btn {
margin-left: $row-base;
height: 35px;
line-height: 35px;
}
}
.select-content {
margin: $col-base $row-base;
flex: 1;
overflow: hidden;
position: relative;
}
.scroll-view-box {
touch-action: none;
flex: 1;
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.sentry {
height: 48px;
}
}
.no-data {
width: auto;
color: #C0C4CB !important;
font-size: 15px;
}
.no-data.center {
text-align: center;
}
}
</style>