flat:所属社区

This commit is contained in:
Apcallover
2024-06-05 16:58:38 +08:00
parent 21835e52c1
commit f338226426
30 changed files with 4215 additions and 189 deletions

View File

@@ -0,0 +1,187 @@
## 4.1.02023-10-22
- 修复小程序端无法移除已选项问题
- 修复小程序端异步加载节点不可用问题
- 增加点击左侧箭头同样可以展开元素
## 4.0.02023-09-17
- 调整 v-model 传参类型,为适应数值和字符串同时存在的树形数据
## 3.8.42023-09-17
- 支持动态加载节点
## 3.8.32023-09-17
- 文档更新
## 3.8.22023-09-17
- 新增路径模式吗,解决重名选项无法辨认问题
## 3.8.12023-08-06
- 修复加载选择器数据后,选择框没有数据问题
## 3.8.02023-07-29
- 修复父子联动状态下,数据回显父级半选状态不展示问题
- selectChange 返回数据调整
- 新增 contentHeight 、disabledList 属性
- 文档更新
## 3.7.92023-07-11
- 文档更新
## 3.7.82023-07-10
- 修复父元素未完全选中状态显示异常问题
## 3.7.72023-07-07
- 文档更新
## 3.7.62023-07-07
- 重写checkbox解决与colorui样式冲突问题
- 父级添加子级未全选状态,更直观了解选中数据情况
- 样式优化,提取文件内样式变量
- 代码体积优化
## 3.7.52023-05-15
- 修复visible在数据初始化时配置有时失效问题
## 3.7.42023-05-11
- 修复父元素下某个子元素设置隐藏时,父子联动状态异常问题
- 对性能以及代码体积做了进一步优化
## 3.7.32023-05-08
- 修复子元素展开懒加载功能被覆盖问题
- 优化数据初始化速度
## 3.7.22023-05-04
- uni.$emit 替换 bus事件
## 3.7.12023-04-10
- 解决小程序无法折叠或展开问题
## 3.7.02023-04-09
- 全选默认不开启,并且只在多选模式下生效
## 3.6.912023-04-09
- 文档更新
## 3.6.92023-04-09
- 代码整体优化
- 新增弹窗安全区配置选项
- 新增全选功能配置选项
- 新增搜索框清除按钮是否直接重置搜索结果选项
- 遗留问题:小程序端搜索后无法折叠或打开
## 3.6.82023-04-07
- 优化deepClone
## 3.6.72023-04-03
- 取消对微信小程序的支持
## 3.6.62023-04-03
- 修复listData刷新后不能正确渲染勾选状态问题
## 3.6.52023-03-30
- 修复小程序节点展开或折叠时指示箭头不变换状态
- 修复回显数据有时不展示问题
## 3.6.32023-03-27
- [紧急] 修复单选状态仍可以多选bug
## 3.6.22023-03-27
- 修复偶尔出现节点无法根据绑定数据改变选中状态问题
## 3.6.12023-03-24
- 修复引入问题
## 3.6.02023-03-24
- 重构去除冗余代码,功能不变
## 3.5.02023-03-23
- 修复没有子元素时点击报错问题
## 3.3.92023-03-22
- 解决搜索时 toLowerCase 报错问题
## 3.3.82023-03-22
- 懒加载优化
## 3.3.72023-03-20
- 样式更新
## 3.3.62023-03-20
- 修复子元素折叠或展开不触发问题
## 3.3.52023-03-15
- isArray 替换 instanceof
## 3.3.42023-03-11
- 修复搜索后无法展开或折叠问题
- 搜索后直接展开搜索项
## 3.3.32023-03-06
- 优化展示效果
- 添加引导线选项
## 3.3.22023-02-22
- 修复搜索内容包含隐藏数据的问题
- 优化搜索触发方式
## 3.3.12023-02-16
- 修复异常
## 3.2.92023-02-16
- 修复编译到小程序报错问题
## 3.2.82023-02-16
- 修复多个组件key重复问题
## 3.2.72023-02-16
- 全新底层架构设计
- 修改默认配色,更显成熟稳重
## 3.2.62023-02-15
- 修改文档
## 3.2.52023-02-15
- 文档修改
## 3.2.32023-02-15
- 修复小程序组件无法折叠问题
## 3.2.22023-02-10
- 文档更新
## 3.2.12023-02-09
- 增加节点展开和关闭全局配置
- 修复搜索后数据无法展开和关闭问题
- 修复搜索后不能自动滚动到顶部问题
## 3.2.02023-01-16
- 修复事件冲突
## 3.1.52023-01-13
- 样式调整
## 3.1.42023-01-13
- 样式修复
## 3.1.32023-01-13
- 文档更新
## 3.1.22023-01-13
- 修复一些已知问题
## 3.1.12023-01-12
- 解决微信小程序bus事件冲突问题
## 3.1.02023-01-11
- 支持微信小程序
## 3.0.32023-01-10
- 更新文档
## 3.0.22023-01-09
- 修复了一些已知问题
## 3.0.12023-01-09
- 修复了一些已知问题
## 3.0.02023-01-09
- 全面支持懒加载,大量数据也能快速打开
## 2.0.012022-12-29
- 文档修改
## 2.0.02022-12-28
- 添加搜索功能
- 完善文档
## 1.9.22022-12-14
- 修复了一些已知问题
## 1.9.12022-12-14
- 修复了一些已知问题
## 1.9.02022-12-14
- 增加选项禁用、隐藏功能
- 文档添加示例
## 1.8.42022-12-13
- 文档修复
## 1.8.32022-12-09
- 样式调整
## 1.8.22022-12-07
- 修复弹窗不同设备显示高度问题
## 1.8.12022-12-06
- 修复了一些已知问题
## 1.8.02022-12-05
- 修复了一些已知问题
- 图标替换图片
## 1.7.02022-12-05
- 增加 disabled 属性,适用于查看页面
- 修复了一些已知问题
## 1.6.02022-12-05
- 修复了一些已知问题
## 1.5.02022-12-05
- 根据绑定数据类型自动判断返回值类型
- 完善文档
## 1.4.02022-12-02
- 修复了一些已知问题
## 1.3.12022-12-02
- 修复了一些已知问题
## 1.3.02022-12-02
- 修复了一些已知问题
## 1.2.012022-12-02
- 文档更新
## 1.2.02022-12-02
- 添加一键清除功能
- 添加多选、单选功能
## 1.1.02022-12-02
- 支持数据回显、已选数据移除
- 对标 uni-easyinput 默认样式
- 支持更多属性和事件
## 1.0.32022-12-01
- 修改信息
## 1.0.22022-12-01
- 更新文档
## 1.0.12022-12-01
- 更新预览截图
## 1.0.02022-12-01
- 插件更新

View File

@@ -0,0 +1,983 @@
<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>

View File

@@ -0,0 +1,363 @@
<template>
<view
class="custom-tree-select-content"
:class="{
border:
border &&
node[dataChildren] &&
node[dataChildren].length &&
node.showChildren
}"
:style="{ marginLeft: `${level ? 14 : 0}px` }"
>
<view v-if="node.visible" class="custom-tree-select-item">
<view class="item-content">
<view class="left">
<view
v-if="node[dataChildren] && node[dataChildren].length"
:class="['right-icon', { active: node.showChildren }]"
@click.stop="nameClick(node)"
>
<uni-icons type="forward" size="14" color="#333"></uni-icons>
</view>
<view v-else class="smallcircle-filled">
<uni-icons
class="smallcircle-filled-icon"
type="smallcircle-filled"
size="10"
color="#333"
></uni-icons>
</view>
<view
v-if="loadingArr.includes(node[dataValue])"
class="loading-icon-box"
>
<uni-icons
class="loading-icon"
type="spinner-cycle"
size="14"
color="#333"
></uni-icons>
</view>
<view
class="name"
:style="node.disabled ? 'color: #999' : ''"
@click.stop="nameClick(node)"
>
<text>{{ node[dataLabel] }}</text>
</view>
</view>
<view
v-if="
choseParent ||
(!choseParent && !node[dataChildren]) ||
(!choseParent && node[dataChildren] && !node[dataChildren].length)
"
:class="['check-box', { disabled: node.disabled }]"
@click.stop="nodeClick(node)"
>
<view
v-if="!node.checked && node.partChecked && linkage"
class="part-checked"
></view>
<uni-icons
v-if="node.checked"
type="checkmarkempty"
size="18"
:color="node.disabled ? '#333' : '#007aff'"
></uni-icons>
</view>
</view>
</view>
<view
v-if="
node.showChildren && node[dataChildren] && node[dataChildren].length
"
>
<data-select-item
v-for="item in listData"
:key="item[dataValue]"
:node="item"
:dataLabel="dataLabel"
:dataValue="dataValue"
:dataChildren="dataChildren"
:choseParent="choseParent"
:border="border"
:linkage="linkage"
:level="level + 1"
:load="load"
:lazyLoadChildren="lazyLoadChildren"
></data-select-item>
</view>
</view>
</template>
<script>
import dataSelectItem from './data-select-item.vue'
import { paging } from './utils'
export default {
name: 'data-select-item',
components: {
'data-select-item': dataSelectItem
},
props: {
node: {
type: Object,
default: () => ({})
},
choseParent: {
type: Boolean,
default: true
},
dataLabel: {
type: String,
default: 'name'
},
dataValue: {
type: String,
default: 'value'
},
dataChildren: {
type: String,
default: 'children'
},
border: {
type: Boolean,
default: false
},
linkage: {
type: Boolean,
default: false
},
level: {
type: Number,
default: 0
},
load: {
type: Function,
default: function () {}
},
lazyLoadChildren: {
type: Boolean,
default: false
}
},
data() {
return {
listData: [],
clearTimerList: [],
loadingArr: []
}
},
computed: {
watchData() {
const { node, dataChildren } = this
return {
node,
dataChildren
}
}
},
watch: {
watchData: {
immediate: true,
handler(newVal) {
const { node, dataChildren } = newVal
if (
node.showChildren &&
node[dataChildren] &&
node[dataChildren].length
) {
this.resetClearTimerList()
this.renderTree(node[dataChildren])
}
}
}
},
methods: {
// 懒加载
renderTree(arr) {
const pagingArr = paging(arr)
this.listData.splice(0, this.listData.length, ...(pagingArr?.[0] || []))
this.lazyRenderList(pagingArr, 1)
},
// 懒加载具体逻辑
lazyRenderList(arr, startIndex) {
for (let i = startIndex; i < arr.length; i++) {
let timer = null
timer = setTimeout(() => {
this.listData.push(...arr[i])
}, i * 500)
this.clearTimerList.push(() => clearTimeout(timer))
}
},
// 中断懒加载
resetClearTimerList() {
const list = [...this.clearTimerList]
this.clearTimerList.splice(0, this.clearTimerList.length)
list.forEach((item) => item())
},
async nameClick(node) {
if (!node[this.dataChildren]?.length && this.lazyLoadChildren) {
this.loadingArr.push(node[this.dataValue])
try {
const res = await this.load(node)
if (Array.isArray(res)) {
uni.$emit('custom-tree-select-load', {
source: node,
target: res
})
}
} finally {
this.loadingArr = []
}
} else {
if (
!node.showChildren &&
node[this.dataChildren] &&
node[this.dataChildren].length
) {
// 打开
this.renderTree(node[this.dataChildren])
} else {
// 关闭
this.resetClearTimerList()
this.listData.splice(0, this.listData.length)
}
uni.$emit('custom-tree-select-name-click', node)
}
},
nodeClick(node) {
if (!node.disabled) {
uni.$emit('custom-tree-select-node-click', node)
}
}
},
options: {
styleIsolation: 'shared'
}
}
</script>
<style lang="scss" scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
$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;
$border-color: #c8c7cc;
.custom-tree-select-content {
&.border {
border-left: 1px solid $border-color;
}
/deep/ .uni-checkbox-input {
margin: 0 !important;
}
.item-content {
margin: 0 0 $col-lg;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 3px;
background-color: #fff;
transform: translateX(-2px);
z-index: 1;
}
.left {
flex: 1;
display: flex;
align-items: center;
.right-icon {
transition: 0.15s ease;
&.active {
transform: rotate(90deg);
}
}
.smallcircle-filled {
width: 14px;
height: 13.6px;
display: flex;
align-items: center;
.smallcircle-filled-icon {
transform-origin: center;
transform: scale(0.55);
}
}
.loading-icon-box {
margin-right: $row-sm;
width: 14px;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
.loading-icon {
transform-origin: center;
animation: rotating infinite 0.2s ease;
}
}
.name {
flex: 1;
}
}
}
}
.check-box {
width: 23.6px;
height: 23.6px;
border: 1px solid $border-color;
border-radius: $radius-sm;
display: flex;
justify-content: center;
align-items: center;
&.disabled {
background-color: rgb(225, 225, 225);
}
.part-checked {
width: 60%;
height: 2px;
background-color: $primary-color;
}
}
@keyframes rotating {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,17 @@
export function isString(data) {
return typeof data === 'string'
}
// 分页
export function paging(data, PAGENUM = 50) {
if (!Array.isArray(data) || !data.length) return data
const pages = []
data.forEach((item, index) => {
const i = Math.floor(index / PAGENUM)
if (!pages[i]) {
pages[i] = []
}
pages[i].push(item)
})
return pages
}

View File

@@ -0,0 +1,83 @@
{
"id": "custom-tree-select",
"displayName": "custom-tree-select树形选择器支持v-model",
"version": "4.1.0",
"description": "树形选择器基于uni-uiv-model绑定数据父级可选、数据回显、移除选项",
"keywords": [
"custom",
"tree-select",
"选择器",
"树形选择器"
],
"repository": "https://github.com/qzlthxp/custom-tree-select",
"engines": {
},
"dcloudext": {
"type": "component-vue",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "u"
},
"App": {
"app-vue": "y",
"app-nvue": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "u",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}

View File

@@ -0,0 +1,228 @@
# custom-tree-select 使用指南
找工作中…,大佬们有内推加群联系我
**提示:** 使用该插件前确保你已经导入 `uni-popup` `uni-icons` `uni-easyinput` 插件。
当前插件主要作为表单中选择器来使用,如果只是作为弹窗会出现数据状态无法重置,此时推荐使用 [`custom-tree-popup`](https://ext.dcloud.net.cn/plugin?name=custom-tree-popup) 组件。
**如果在微信小程序中使用,在 `main.js` 文件中添加以下代码,2023/05/04 之前安装插件或者本地插件版本 `<=3.7.1` 需要添加**
```js
// #ifdef MP-WEIXIN
Vue.prototype.$bus = new Vue()
// #endif
```
**有问题可以加 QQ 群297080738**
## 优势
💪:基于 `uni-popup``uni-icons``uni-easyinput` 插件进行开发,默认样式与 `uni-easyinput` 样式对标。
⚡:全面支持懒加载应对大量数据。
🚀v-model 绑定数据、数据回显、移除选项。
⚙ :提供更多配置项。
📦:开箱即用。
## Props
| 属性名 | 类型 | 默认值 | 说明 | 版本要求 |
| :-------------------: | :---------: | :-----------------------------: | :----------------------------------------------------------: | --------- |
| canSelectAll | Boolean | false | 开启一键全选功能 | |
| contentHeight | String | '500px' \| 视窗高度的 75%的像素 | 弹出内选择器容器高度,为解决搜索时搜索框被顶出屏幕,监听输入框 focus 事件 动态修改弹窗内选择器容器高度 | |
| clearResetSearch | Boolean | false | 设置为 `true` 并且搜索之后,点击输入框清除按钮,会清空搜索内容并且会直接重置整个弹窗内树形选择器内容,默认情况下只有清除之后再次进行查询才会重置选择器 | |
| animation | Boolean | ture | 是否开启弹窗动画 | |
| is-mask-click | Boolean | true | 点击遮罩关闭弹窗 | |
| mask-background-color | String | rgba(0,0,0,0.4) | 蒙版颜色,建议使用 rgba 颜色值 | |
| background-color | String | none | 主窗口背景色 | |
| safe-area | Boolean | true | 是否适配底部安全区 | |
| **choseParent** | **Boolean** | **true** | **父节点是否可选** | |
| **linkage** | **Boolean** | **false** | **父子节点是否联动** | |
| placeholder | String | 请选择 | 空状态信息提示、弹窗标题 | |
| confirmText | String | 完成 | 确定按钮文字 | |
| confirmTextColor | String | #007aff | 确定按钮文字颜色 | |
| listData | Array | - | 展示的数据 | |
| **dataLabel** | **String** | **name** | **listData 中对应数据的 label** | |
| **dataValue** | **String** | **id** | **listData 中对应数据的 value** | |
| **dataChildren** | **String** | **children** | **listData 中对应数据的 children** | |
| clearable | Boolean | false | 是否显示清除按钮,点击清除所有已选项 | |
| **mutiple** | **Boolean** | **false** | **是否可以多选** | |
| **disabled** | **Boolean** | **false** | **是否允许修改** | |
| disabledList | Array | [] | 设置某些选项为不可选,数组元素为数据 dataValue 对应的值,也可以操作数据 disabled 属性实现 | |
| search | Boolean | false | 是否可以搜索(常用于数据较多的情况) | |
| showChildren | Boolean | false | 默认展开(数据内部 showChildren 属性优先级更高,可以设置全局收起,单独展开某一条数据) | |
| border | Boolean | false | 显示引导线 | |
| pathMode | Boolean | false | 路径模式,开启后选择框内展示选项所在层级的完整信息如:`城市/街道/小区` | >= 3.8.2 |
| pathHyphen | String | / | 路径模式下连字符 | >= 3.8.2 |
| lazyLoadChildren | Boolean | false | 是否允许动态加载获取子节点 | >= 3.8.4 |
| load | Function | (node) => Promise<void> | 动态加载函数,具体用法见下方示例 | >= 3.8.4 |
| **v-model/value** | **Array** | **[ ]** | **已选择的值,通过 v-model 进行绑定例如v-model="formData.selectedList"** (根据你绑定数据的类型自动返回相同类型的数据String 类型通过 `,` 进行分隔。>=4.0.0 版本不在支持字符串类型传参,修改为数组类型) | >= 4.0.0 |
## listdata 特有属性
| 名称 | 类型 | 默认值 | 说明 |
| ------------ | ------- | ------ | ------------------ |
| disabled | Boolean | false | 选项是否可选 |
| visible | Boolean | true | 选项是否展示 |
| showChildren | Boolean | true | 选项是否展示子节点 |
## Events
| 事件名称 | 说明 | 返回值 |
| ------------ | ------------------------ | -------------------------------------- |
| change | 弹窗组件状态发生变化触发 | e={show: true false, type:当前模式} |
| maskClick | 点击遮罩层触发 | |
| input | 选中数据或取消选中时触发 | 以数组形式返回已选择数据 |
| selectChange | 选中数据或取消选中时触发 | 以数组形式返回已选择数据完整信息 |
| removeSelect | 选择框移除选项时触发 | 返回对应数据的完整信息 |
## 基础使用示例
```vue
<template>
<!--/pages/index/index-->
<custom-tree-select :listData="listData" v-model="formData.selectedArr" />
</template>
<script>
export default {
data() {
return {
formData: {
selectedArr: [],
},
listData: [
{
id: 1,
name: '城市1',
children: [
{
id: 3,
name: '街道1',
children: [
{
id: 4,
name: '小区1'
}
]
}
]
},
{
id: 2,
name: '城市2',
children: [
{
id: 6,
name: '街道2'
}
]
}
]
}
}
}
</script>
```
## 禁用某些选项,或隐藏某些选项
```vue
<template>
<!--/pages/index/index-->
<custom-tree-select
mutiple
linkage
clearable
search
dataLabel="text"
dataValue="value"
:listData="listData"
:disabledList="[6]"
v-model="formData.selected"
></custom-tree-select>
</template>
<script>
export default {
data() {
return {
formData: {
selected: []
},
listData: [
{
value: 1,
text: '城市1',
children: [
{
value: 3,
text: '街道1',
disabled: true
}
]
},
{
value: 2,
text: '城市2',
children: [
{
value: 6,
text: '街道2'
}
]
},
{
value: 7,
text: '城市3',
visible: false
}
]
}
}
}
</script>
```
## 动态加载节点
```vue
<custom-tree-select
:listData="listData"
:load="load"
lazyLoadChildren
pathMode
v-model="selectedArr"
/>
<script>
export default {
data() {
return {
selectedArr: []
}
},
methods: {
load(node) {
return new Promise((resolve, reject) => {
if (node) {
setTimeout(() => {
resolve([
{
value: '128047129041',
text: '测试异步加载'
}
])
}, 2000)
}
})
}
}
}
</script>
```