| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358 |
- <template>
- <view class="width-100">
- <view class="d-flex a-center" style="width:100%">
- <view @click="openPopup" class="d-flex px2" style="width:100%">
- <view class="input-box flex1">
- <view v-if="inputLabel" class="text-color-3">{{ inputLabel }}</view>
- <view v-else class="text-color-9">{{ placeholder }}</view>
- </view>
- <u-icon name="arrow-right"></u-icon>
- </view>
- </view>
- <u-popup mode="bottom" v-model="showPopup" length="auto" :safeAreaInsetBottom="true" :maskCloseAble="true">
- <view class="ts-popup">
- <view class="ts-header" @touchmove.stop.prevent="">
- <view class="ts-btn ts-btn--cancel" hover-class="ts-opacity" :hover-stay-time="150" @tap="handleCancel">取消
- </view>
- <view class="ts-title">{{ title }}</view>
- <view class="ts-btn ts-btn--confirm" hover-class="ts-opacity" :hover-stay-time="150" @tap="handleConfirm">确认
- </view>
- </view>
- <scroll-view scroll-y class="ts-body">
- <view v-for="(item, index) in visibleNodes" :key="index" class="ts-item"
- :class="{ 'ts-item--active': tempValue === item.node[valueName] }"
- :style="{ paddingLeft: item.depth * 40 + 30 + 'rpx' }" @click="selectNode(item)">
- <view class="ts-item-label">{{ item.node[labelName] }}</view>
- <u-icon v-if="tempValue === item.node[valueName]" name="checkmark" size="28" color="#0082ff"></u-icon>
- <view v-if="hasChildren(item.node)" class="ts-arrow" @click.stop="toggleExpand(item.node)">
- <u-icon :name="isExpanded(item.node) ? 'arrow-down' : 'arrow-right'" size="26" color="#909399"></u-icon>
- </view>
- </view>
- <view v-if="!visibleNodes.length" class="ts-empty">暂无数据</view>
- </scroll-view>
- </view>
- </u-popup>
- </view>
- </template>
- <script>
- import Emitter from '@/uview-ui/libs/util/emitter.js'
- /**
- * treeSelect 树形选择表单组件
- * @description 外观与 my-select 一致的表单选择框,点击后从底部弹出树形选择面板:
- * 父节点可通过右侧箭头展开/收起子级,点击任意节点(父/子)即选中,
- * 点击“确认”后写回 v-model,并派发 u-form 校验事件。
- * @property {String Number} value 选中节点的值(v-model)
- * @property {Array} data 树形数据,形如 [{ id, title, children: [...] }]
- * @property {Array} list 树形数据(data 的别名,兼容旧用法)
- * @property {String} labelName 节点显示文本对应的字段名(默认title)
- * @property {String} valueName 节点值对应的字段名(默认id)
- * @property {String} childrenName 子节点数组对应的字段名(默认children)
- * @property {String} placeholder 未选中时的占位文案(默认请选择)
- * @property {String} title 弹出面板顶部标题(默认请选择)
- * @property {Boolean} disabled 是否禁用选择(默认false)
- * @event {Function} input 确认选中后触发(v-model)
- * @event {Function} selected 确认选中后触发,返回 (value, 节点对象)
- * @example <tree-select v-model="form.sbCph" :data="sbPositionData" />
- */
- export default {
- name: 'TreeSelect',
- mixins: [Emitter],
- props: {
- // 选中节点的值(v-model)
- value: {
- type: [String, Number],
- default: ''
- },
- // 树形数据
- data: {
- type: Array,
- default() {
- return []
- }
- },
- // 树形数据(data 的别名)
- list: {
- type: Array,
- default() {
- return []
- }
- },
- // 显示文本字段名
- labelName: {
- type: String,
- default: 'title'
- },
- // 值字段名
- valueName: {
- type: String,
- default: 'id'
- },
- // 子节点字段名
- childrenName: {
- type: String,
- default: 'children'
- },
- placeholder: {
- type: String,
- default: '请选择'
- },
- title: {
- type: String,
- default: '请选择'
- },
- disabled: {
- type: [String, Number, Boolean],
- default: false
- }
- },
- data() {
- return {
- showPopup: false,
- inputLabel: '',
- // 弹框内临时选中的值与文本,确认后才写回
- tempValue: '',
- tempLabel: '',
- // 弹框内已展开的节点 value 集合
- expandedKeys: []
- }
- },
- computed: {
- treeData() {
- return this.data && this.data.length ? this.data : this.list
- },
- // 将树按展开状态拍平为可见节点列表:[{ node, depth }]
- visibleNodes() {
- const result = []
- const walk = (nodes, depth) => {
- if (!nodes || !nodes.length) return
- for (let i = 0; i < nodes.length; i++) {
- const node = nodes[i]
- result.push({ node, depth })
- if (this.isExpanded(node) && this.hasChildren(node)) {
- walk(node[this.childrenName], depth + 1)
- }
- }
- }
- walk(this.treeData, 0)
- return result
- }
- },
- watch: {
- value() {
- this.initByValue()
- },
- treeData() {
- this.initByValue()
- }
- },
- created() {
- this.$nextTick(() => {
- this.initByValue()
- })
- },
- methods: {
- openPopup() {
- if (this.disabled) {
- uni.showToast({ title: '不允许选择', icon: 'none' })
- return
- }
- // 回显当前值:临时选中 + 自动展开其所有父级
- this.tempValue = this.value
- this.tempLabel = this.inputLabel
- const path = this.findPath(this.treeData, this.value) || []
- const keys = []
- let nodes = this.treeData
- for (let i = 0; i < path.length - 1; i++) {
- keys.push(nodes[path[i]][this.valueName])
- nodes = nodes[path[i]][this.childrenName]
- }
- this.expandedKeys = keys
- this.showPopup = true
- },
- handleCancel() {
- this.showPopup = false
- },
- handleConfirm() {
- const found = this.findNodeByValue(this.treeData, this.tempValue)
- if (this.tempValue !== '' && this.tempValue !== null && this.tempValue !== undefined && found) {
- this.inputLabel = found[this.labelName]
- if (found[this.valueName] + '' !== this.value + '') {
- this.$emit('input', found[this.valueName])
- this.$emit('selected', found[this.valueName], found)
- this.dispatch('u-form-item', 'on-form-change')
- }
- }
- this.showPopup = false
- },
- // 点击节点:选中;若为父节点则同时展开其子级,方便继续选子选项
- selectNode(item) {
- this.tempValue = item.node[this.valueName]
- this.tempLabel = item.node[this.labelName]
- if (this.hasChildren(item.node) && !this.isExpanded(item.node)) {
- this.expandedKeys = [...this.expandedKeys, item.node[this.valueName]]
- }
- },
- // 点击箭头:仅展开/收起,不改变选中
- toggleExpand(node) {
- const key = node[this.valueName]
- if (this.isExpanded(node)) {
- this.expandedKeys = this.expandedKeys.filter(k => k !== key)
- } else {
- this.expandedKeys = [...this.expandedKeys, key]
- }
- },
- isExpanded(node) {
- return this.expandedKeys.indexOf(node[this.valueName]) !== -1
- },
- hasChildren(item) {
- const children = item[this.childrenName]
- return !!(children && children.length)
- },
- // 递归查找 value 对应节点,返回 { path, node }
- findNode(nodes, path = []) {
- if (this.value === '' || this.value === null || this.value === undefined) {
- return null
- }
- return this.findNodeByValue(nodes, this.value, path)
- },
- // 递归查找 value 对应节点的下标路径
- findPath(nodes, val, path = []) {
- if (!nodes || !nodes.length || val === '' || val === null || val === undefined) {
- return null
- }
- for (let i = 0; i < nodes.length; i++) {
- const node = nodes[i]
- if (node[this.valueName] == val) {
- return [...path, i]
- }
- const children = node[this.childrenName]
- if (children && children.length) {
- const found = this.findPath(children, val, [...path, i])
- if (found) return found
- }
- }
- return null
- },
- // 按给定值递归查找节点
- findNodeByValue(nodes, val, path = []) {
- if (!nodes || !nodes.length || val === '' || val === null || val === undefined) {
- return null
- }
- for (let i = 0; i < nodes.length; i++) {
- const node = nodes[i]
- if (node[this.valueName] == val) {
- return node
- }
- const children = node[this.childrenName]
- if (children && children.length) {
- const found = this.findNodeByValue(children, val, [...path, i])
- if (found) return found
- }
- }
- return null
- },
- // 根据外部传入的 value 回显输入框文本
- initByValue() {
- const found = this.findNode(this.treeData)
- this.inputLabel = found ? found[this.labelName] : ''
- }
- }
- }
- </script>
- <style scoped>
- .width-100 {
- width: 100%;
- }
- .ts-popup {
- background-color: #fff;
- }
- .ts-header {
- width: 100%;
- height: 90rpx;
- padding: 0 40rpx;
- display: flex;
- justify-content: space-between;
- align-items: center;
- box-sizing: border-box;
- font-size: 30rpx;
- background: #fff;
- position: relative;
- }
- .ts-header::after {
- content: '';
- position: absolute;
- border-bottom: 1rpx solid #eaeef1;
- -webkit-transform: scaleY(0.5);
- transform: scaleY(0.5);
- bottom: 0;
- right: 0;
- left: 0;
- }
- .ts-title {
- color: #606266;
- }
- .ts-btn {
- padding: 16rpx;
- box-sizing: border-box;
- text-align: center;
- }
- .ts-btn--cancel {
- color: #606266;
- }
- .ts-btn--confirm {
- color: #2979ff;
- }
- .ts-opacity {
- opacity: 0.5;
- }
- .ts-body {
- max-height: 600rpx;
- padding-bottom: 20rpx;
- }
- .ts-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-top: 20rpx;
- padding-bottom: 20rpx;
- padding-right: 30rpx;
- font-size: 28rpx;
- color: #303133;
- }
- .ts-item--active {
- color: #0082ff;
- font-weight: 600;
- }
- .ts-item-label {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .ts-arrow {
- padding: 6rpx 10rpx;
- }
- .ts-empty {
- text-align: center;
- color: #909399;
- font-size: 26rpx;
- padding: 40rpx 0;
- }
- </style>
|