vue2+vant2 实现带搜索的级联选择组件

级联选择组件

新建组件-子项

  • CasadeSelect/CasadeSelectItem.vue
<template>
  <div class="container-list-item">
    <div class="icon">
      <div class="checkbox">
        <svg-icon v-if="curChecked" icon-class="radio" @click.stop="clickBox(false, item)" />
        <van-icon v-else name="circle" @click.stop="clickBox(true, item)" />
      </div>
    </div>
    <div class="info">
      <div class="word text-cut">{{ item[sLabel] }}</div>
      <div v-if="hasChildren" class="right blue" @click="clickItem($event, item)">
        <van-icon name="cluster-o" />
        下级
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: 'CasadeSelectItem',
  props: {
    // 传入的数值
    item: {
      type: Object,
      default: () => {}
    },
    // 是否选中状态
    checked: {
      type: Boolean,
      default: false
    },
    // 是否只能选择叶子结点
    nodes: {
      type: Boolean,
      default: false
    },
    // 列名称对照表
    comparison: {
      type: Object,
      default: () => {
        return {
          value: 'value', // 选中值
          label: 'label', // 显示名称
          children: 'children' // 子级名称
        }
      }
    }
  },
  data() {
    return {
      sLabel: this.comparison.label ? this.comparison.label : 'label', // label值名称
      sChildren: this.comparison.children ? this.comparison.children : 'children', // children值名称
      curChecked: this.checked //是否选中状态
    }
  },
  computed: {
    /**
     * 是否包含子级
     */
    hasChildren: function () {
      return this.item ? (this.item[this.sChildren] ? this.item[this.sChildren].length > 0 : false) : false
    }
  },
  watch: {
    // 监听列名对照表变化
    comparison: {
      handler() {
        this.sLabel = this.comparison.label ? this.comparison.label : 'label'
        this.sChildren = this.comparison.children ? this.comparison.children : 'children'
      },
      deep: true
    },
    // 是否选中状态
    checked: function (val) {
      this.curChecked = val
    }
  },
  methods: {
    /** 点击当前项的执行方法
     */
    clickItem: function (e, item) {
      this.$emit('clickItem', item, this.hasChildren)
      // 不包含下一级,修改check值事件
      if (!this.hasChildren && this.isCheck) {
        this.clickBox(e)
      }
    },
    /**
     * 点击单选框或复选框
     */
    clickBox: function (checked) {
      this.curChecked = !this.curChecked
      this.$emit('change', checked)
    }
  }
}
</script>

<style lang="less" scoped>
@import './index';
</style>

新建组件-面包屑

CasadeSelect/CasadeSelectNavigation.vue

<template>
  <div class="title">
    <div ref="sea" class="flex nav">
      <!-- 全部 -->
      <div class="inline-item" @click="clickItem(null, -1)">
        <span v-if="!isre && treeStack.length == 0" class="none">全部</span>
        <span v-else class="active">全部</span>
      </div>
      <!-- 全部 -->
      <!-- 搜索结果 -->
      <div v-if="isre" @click="clickItem(null, -2)" class="inline-item" :class="activeSearch ? 'active' : 'none'">
        <van-icon name="arrow" class="gray-6" />
        搜索结果
      </div>
      <!-- 搜索结果 -->
      <!-- 当前树的层级值 -->
      <div v-for="(item, index) in treeStack" class="inline-item" :key="index">
        <div class="inline-item" @click="clickItem(item, index)">
          <van-icon name="arrow" class="gray-6" />
          <span v-if="index == treeStack.length - 1" class="none inline-item">
            {{ item[slabel] }}
          </span>
          <span v-else class="active">
            {{ item[slabel] }}
          </span>
        </div>
      </div>
      <!-- 当前树的层级值 -->
    </div>
  </div>
</template>

<script>
export default {
  name: 'CasadeSelectNavigation',
  props: {
    // 显示的label值
    slabel: {
      type: String,
      default: 'label'
    }
  },
  data() {
    return {
      isre: false, // 是否进行了搜索(返回是否进行了搜索)
      treeStack: [] // 当前搜索值
    }
  },
  computed: {
    // 是否可点击搜索结果
    activeSearch: function () {
      return this.treeStack.length > 0
    }
  },
  created: function () {
    // 浅拷贝导航列表的每一个对象(为了不改变item值,也不复制过多的数据)
    this.treeStack.forEach(item => {
      var tempItem = Object.assign(item)
      this.treeStack.push(tempItem)
    })
    var obj = {
      setIsre: this.setIsre,
      getIsre: this.getIsre,
      setTreeStack: this.setTreeStack,
      concatTreeStack: this.concatTreeStack,
      pushTreeStack: this.pushTreeStack,
      clearTreeStack: this.clearTreeStack,
      getTreeStack: this.getTreeStack
    }
    this.$emit('inF', obj) //  导出的导航栏调用方法
  },
  methods: {
    /** 设置isre值(是否搜索)
     */
    setIsre: function (isre) {
      this.isre = isre
    },
    /**
     * 获取isr值(获取是否搜索中)
     */
    getIsre: function () {
      return this.isre
    },
    /** 设置导航树
     */
    setTreeStack: function (treeStack) {
      this.treeStack = treeStack
    },
    /** 拼接导航树
     */
    concatTreeStack: function (treeStack) {
      this.treeStack = this.treeStack.concat(treeStack)
    },
    /** 为导航树添加项
     */
    pushTreeStack: function (item) {
      this.treeStack.push(item)
    },
    /**
     * 获取当前导航条
     */
    getTreeStack: function () {
      return this.treeStack
    },
    /**
     * 清空导航树
     */
    clearTreeStack: function () {
      this.treeStack.splice(0)
    },
    /** 点击导航栏索引
     */
    clickItem(item, index) {
      if (index == -1) {
        // 点击全部
        this.isre = false
        this.treeStack.splice(0)
      } else if (index == -2) {
        // 搜索结果
        if (this.activeSearch) {
          this.isre = true
          this.treeStack.splice(0)
        }
      } else {
        // 点击某一层级树
        this.isre = false
        if (this.treeStack.length - 1 > index) {
          this.treeStack.splice(index + 1)
        }
      }
      this.$emit('clickItem', item, index)
    }
  }
}
</script>

<style lang="less" scoped>
.inline-item {
  color: #011b4c;
}
.nav {
  width: 100%;
  white-space: nowrap;
  overflow: auto;
}
.active {
  color: #0056fb;
}
</style>

新建组件-搜索

CasadeSelect/CasadeSelectSearch.vue

<template>
  <div>
    <!-- 输入框内容 -->
    <van-search
      class="text"
      type="text"
      v-model="inputVal"
      confirm-type="搜索"
      placeholder="请输入"
      @input="handleInput"
      @focus="handleFocus"
      @blur="handleBlur"
      @search="handleFllter"
      @clear="clears"
    />
  </div>
</template>

<script>
export default {
  name: 'CasadeSelectSearch',

  data() {
    return {
      inputVal: '' // 输入内容
    }
  },

  methods: {
    /** 输入框变化时方法
     */
    handleInput() {
      this.$emit('input', this.inputVal)
    },
    /** 输入框聚焦时触发
     */
    handleFocus() {
      this.$emit('focus', this.inputVal)
    },
    /** 输入框失去焦点时触发
     */
    handleBlur() {
      this.$emit('blur', this.inputVal)
    },
    /** 提交内容时触发
     */
    handleFllter() {
      this.$emit('confirm', this.inputVal)
    },
    /**
     * 清空输入框内容
     */
    clears: function () {
      this.inputVal = ''
      this.$emit('clear', this.inputVal)
    }
  }
}
</script>

<style lang="less" scoped>
.filterBox {
  padding: 15px 32px;

  .filter-input {
    height: 80px;
    display: flex;
    align-items: center;
    padding-left: 40px;

    .filterImg {
      width: 32px;
      height: 32px;
      margin-right: 20px;
      margin-bottom: 5px;
    }

    .filterImgs {
      width: 32px;
      height: 32px;
    }

    .text {
      width: 100%;
      font-size: 32px;
      color: #000;
    }
  }
}

// 添加左侧padding(用于扩大图标范围)
.padding-left-sm {
  padding-left: 20px;
}
</style>

新建组件-index

  • CasadeSelect/index.vue
<template>
  <div>
    <!-- 搜索框 -->
    <div class="header">
      <!-- 搜索栏 -->
      <casade-select-search @confirm="confirmSearch" />
      <!-- 面包屑导航 -->
      <casade-select-navigation :slabel="props.label" ref="navigation" @inF="navigationInt" @clickItem="backTree" />
    </div>
    <!-- 列表 -->
    <div>
      <div class="container-list">
        <casade-select-item
          v-for="(item, index) in tree"
          :key="index"
          :item="item"
          :checked="newCheckList.includes(item.id)"
          :nodes="props.nodes"
          :comparison="comparison"
          @clickItem="toChildren"
          @change="checked => radioChange(checked, item, index)"
        />
      </div>
    </div>

    <!-- 确定按钮 -->
    <div class="padding-lr-sm padding-tb-sm">
      <van-button block round class="sureBtn" type="info" @click="backConfirm">确认</van-button>
    </div>
  </div>
</template>

<script>
import { Toast } from 'vant'
import CasadeSelectSearch from './CasadeSelectSearch'
import CasadeSelectItem from './CasadeSelectItem'
import CasadeSelectNavigation from './CasadeSelectNavigation'
export default {
  name: 'CasadeSelect',
  components: { CasadeSelectSearch, CasadeSelectItem, CasadeSelectNavigation },
  props: {
    // 传入的树形结构数据,每个对象必须包含唯一的id值
    trees: {
      type: Array,
      default: () => {
        return []
      }
    },
    // 选中列表
    checkList: {
      type: Array,
      default: () => []
    },
    // 父级列表
    parentList: {
      type: Array,
      default: () => []
    },
    // 树的属性参数
    props: {
      type: Object,
      default: () => {
        return {
          id: 'id',
          label: 'name',
          children: 'children',
          checkStrictly: false, //不关联
          nodes: false // nodes为false时,可以选择任意一级选项;nodes为true时只能选择叶子节点
        }
      }
    },
    /**
     * 是否懒加载树的值
     */
    stepReload: {
      type: Boolean,
      default: false
    },
    // 每次循环加载的item的数据量
    pageSize: {
      type: Number,
      default: 50
    }
  },
  data() {
    return {
      // 导航条
      setIsre: null, // 导航条方法 - 设置是否搜索中方法
      getIsre: null, // 获取是否搜索中
      setTreeStack: null, // 导航条 - 设置导航
      concatTreeStack: null, // 导航条 - 拼接当前导航对象
      clearTreeStack: null, // 导航条- 清空导航条
      getTreeStack: null, // 导航条 - 获取导航条

      itemsLoading: false, // item是否在循环渲染中
      itemsStop: false, // 是否终止其他渲染
      tree: [], // 默认数组
      newNum: 0,
      oldNum: 0,
      allData: this.trees,
      parent_data: this.parentList || [], //选择父辈
      searchResult: [],
      newCheckList: this.checkList,
      nodePathArray: [], // 当前展示的路径
      // item名称对照表
      comparison: {
        value: this.props.id ? this.props.id : 'id', // 选中值名称
        label: this.props.label ? this.props.label : 'name', // 显示名称
        children: this.props.children ? this.props.children : 'children' // 子集名称
      }
    }
  },
  watch: {
    checkList() {
      this.newCheckList = this.checkList
      this.setSelected()
    },
    // 监听数据值的变化
    trees(val, oldval) {
      if (val != oldval) {
        var tree_stack = this.getTreeStack()
        this.allData = val // 重新加载所有树
        // 重新加载当前树
        if (!Array.isArray(tree_stack)) {
          this.loadTree(val)
          return
        }
        var length = tree_stack.length
        if (length === 0) {
          if (typeof this.getIsre === 'function') {
            if (this.getIsre()) {
              return
            }
          }
          this.loadTree(val)
        } else {
          let tempArray = val // 存储当前值
          let children = this.props.children
          for (var i = 0; i < length; i++) {
            var tempObject = tempArray.find(item => {
              return tree_stack[i].Value == item.Value
            })
            if (tempObject) {
              tempArray = tempObject[children]
            } else {
              // 跳转到全部
              break
            }
            if (i == length - 1) {
              this.loadTree(tempArray)
            }
          }
        }
      }
    },
    // 树的属性对照参数
    props: {
      handler: function () {
        this.comparison.value = this.props.id ? this.props.id : 'id'
        this.comparison.label = this.props.label ? this.props.label : 'name'
        this.comparison.children = this.props.children ? this.props.children : []
      },
      deep: true
    }
  },
  created: function () {
    this.loadTree(this.trees)
  },
  // 实例被挂载后调用
  mounted() {},
  methods: {
    /*
     * 默认选中
     * */
    setSelected() {
      // 初始化选中项
      this.$nextTick(() => {
        let children = this.props.children
        if (this.newCheckList.length > 0) {
          this.getNodeRoute(this.allData, this.newCheckList[0])
          let arr = this.nodePathArray.reverse()
          if (arr.length == 0) {
            return
          }

          this.concatTreeStack(arr) // 获取导航条的值
          var tree_stack = this.getTreeStack()
          var data = tree_stack[tree_stack.length - 1][children] ? tree_stack[tree_stack.length - 1][children] : []
          this.loadTree(data)
        }
      })
    },
    /** 初始化导航条的方法
     */
    navigationInt(e) {
      this.setIsre = e.setIsre
      this.getIsre = e.getIsre
      this.concatTreeStack = e.concatTreeStack
      this.pushTreeStack = e.pushTreeStack
      this.clearTreeStack = e.clearTreeStack
      this.getTreeStack = e.getTreeStack
    },
    /**单选
     */
    radioChange: function (checked, item, index) {
      let id = this.props.id
      if (checked) {
        // 选中当前对象
        this.newCheckList = []
        this.newCheckList.push(this.tree[index][id])
      } else {
        // 移除其他对象
        var nIndex = this.newCheckList.indexOf(item[id])
        this.newCheckList.splice(nIndex, 1)
      }
      this.$emit('change', this.newCheckList)
    },
    /**
     * @param {Array} tree 目标树
     * @param {Object} targetId 为目标节点id
     */
    getNodeRoute(tree, targetId) {
      let children = this.props.children
      let id = this.props.id
      for (let index = 0; index < tree.length; index++) {
        if (tree[index][children]) {
          if (tree[index][children]) {
            let endRecursiveLoop = this.getNodeRoute(tree[index][children], targetId)
            if (endRecursiveLoop) {
              this.nodePathArray.push(tree[index])
              return true
            }
          }
        }
        if (tree[index][id] === targetId) {
          return true
        }
      }
    },

    /**跳转到子级
     */
    toChildren(item, realHasChildren) {
      this.$emit('clickItem', item, realHasChildren) // 点击导航栏事件
      // 不包含子级,不执行任何操作
      if (!realHasChildren) {
        return
      }
      // 点击跳转下一级
      let children = this.props.children // 子级名称
      // 将当前item加入到导航列表
      if (item[children].length > 0) {
        this.loadTree(item[children])
        this.pushTreeStack(item) // 添加导航
      }
    },

    /** 返回到其他树层
     */
    backTree(item, index) {
      this.$emit('backTree', item, index)
      let that = this
      if (index == -1) {
        // 全部
        that.loadTree(that.allData)
      } else if (index == -2) {
        // 搜索
        that.loadTree(that.searchResult) // 搜索结果
      } else {
        // 其他层级
        that.loadTree(item[that.props.children]) // tree的其他层级
      }
    },
    /**
    /** 搜索提交方法
     */
    confirmSearch(val) {
      this.searchResult = []
      // 查找
      this.$tips.loading('搜索中...')
      this.search(this.allData, val)
      // 返回搜索结果
      Toast.clear()
      this.setIsre(true) // 设置导航条为搜索状态
      this.clearTreeStack() // 清空导航条
      this.loadTree(this.searchResult)
    },
    /**搜索方法
     */
    search(data, keyword) {
      var that = this
      let children = that.props.children
      for (var i = 0, len = data.length; i < len; i++) {
        // try-catch(try-catch) - 没有label列,跳过继续执行
        try {
          if (data[i][that.props.label].indexOf(keyword) >= 0) {
            that.searchResult.push(data[i])
          }
          if (data[i][children]) {
            if (data[i][children].length > 0) {
              that.search(data[i][children], keyword)
            }
          }
        } catch (e) {
          console.warn(e)
        }
      }
    },

    /**
     * 点击确认按钮执行事件
     */
    backConfirm() {
      this.$emit('sendValue', this.newCheckList, 'back')
    },
    /**加载Tree值
     */
    loadTree(datas, start = 0) {
      let that = this
      if (!this.stepReload) {
        // 不进行多次渲染加载
        that.tree = datas
      } else {
        // datas为null, 不进行渲染
        if (!Array.isArray(datas)) {
          that.tree = datas
          return
        } else if (datas.length === 0) {
          that.tree = datas
          return
        }
        // 进行多次渲染加载
        if (start === 0) {
          // 终止其他渲染
          if (that.itemsLoading) {
            that.itemsStop = true //终止其他Item渲染
          }
          // 首次加载提醒
          this.$tips.loading()
          that.tree = []
          that.itemsLoading = true
        }
        var length = datas.length
        var end = Math.min(start + that.pageSize, length)
        var tempArray = datas.slice(start, end)
        that.tree = that.tree.concat(tempArray)
        that.$nextTick(function () {
          if (start == 0) {
            Toast.clear()
            that.itemsStop = false
          }
          if (end < length && !that.itemsStop) {
            that.loadTree(datas, end)
          } else {
            that.itemsLoading = false
          }
        })
      }
    }
  }
}
</script>
<style lang="less" scoped>
@import 'index.less';
</style>

新建样式-index.less

  • CasadeSelect/index.less
.flex_between_center {
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.checkbox {
  position: relative;
  height: 18px;
  margin-left: 5px;
  margin-right: 0px;
  width: 18px;
  .color {
    color: #0056fb;
    background-color: #0056fb;
  }
  .txt {
    // font-size: 15px;
    line-height: 18px;
    width: 100%;
    height: 100%;
    display: flex;
  }
}
.checkBorder {
  border: 1px solid #ecdee4;
}
.header {
  width: 100%;
  //position: fixed;
  background-color: #fff;
  z-index: 9999;
  .title {
    height: 45px;
    padding: 0 16px;
    line-height: 45px;
    font-size: 15px;
    background-color: #f5f5f5;
    color: #606064;
    .iconclass {
      display: inline-block;
      margin: 0 6px;
      color: #D0D4DB;
      font-size: 14px;
    }
  }
}
.container-list {
  overflow-y: scroll;
  overflow-x: hidden;
  .common {
    background-color: #fff;
    border-bottom: 1px solid #f4f4f4;
    padding-left: 5px;
    .content {
      display: flex;
      align-items: center;
      min-height: 30px;
      width: 100%;
      padding: 8px 0;
      position: relative;
      font-size: 16px;

      .right {
        position: absolute;
        right: 15px;
        color: #babdc3;
        font-size: 16px;
      }
    }
  }

}
.active {
  color:#0056fb !important;
}
.none {
  color: #666666;
}
.icon-selected{
  color: #0056fb!important;
  font-size: 20px!important;
}
.icons{
  color: #0056fb!important;
  font-size: 20px!important;
}
.inline-item {
  display: inline-block
}

.content-item{
  display: flex;
  position: relative;
  align-items: center;
}

.box_sizing {
  -webkit-box-sizing: border-box;
  -moz-box-sizing: border-box;
  box-sizing: border-box;
}

.btn {
  //position: fixed;
  bottom: 0;
  padding: 5px;
  background-color: #fff;
  width: 100%;

  .sureBtn {
    background-color: #0056fb;
    color: #fff;
  }
}
.container-list-item {
  display: flex;
  font-size: 14px;
  align-items: center;
  width: calc(100% - 20px);
  margin: 0 10px ;
  padding: 10px 0;
  border-bottom: 1px solid #D9DDE4;
  .icon {
    font-size: 17px;
  }
  .info {
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    white-space: nowrap;
    margin-left: 10px;
  }
  // item的数字样式
  .word {
    font-size: 15px;
    color: #5b5757;
    width: 100%;
    word-break: break-all;
  }
}
.container-list {
  height: 40vh;
}

调用组件

<template>
    <div>
      <div class="value" @click="showPicker = true" :class="fieldValue ? 'black' : 'gray-6'">
        {{ fieldValue ? fieldValue : '请选择' }}
      </div>
      <van-popup get-container="body" v-model="showPicker" round position="bottom" safe-area-inset-bottom>
        <van-cascader
          v-model="cascaderValue"
          title="所属部门"
          :options="options"
          @close="showPicker = false"
          @finish="onFinish"
        />
      </van-popup>
    </div>
</template>

<script>
export default {
  name: 'CascaderPicker',
  data() {
    return {
      showPicker: false,
      fieldValue: '',
      cascaderValue: '',
      // 选项列表,children 代表子选项,支持多级嵌套
      options: []
    }
  },
  methods: {
    // 全部选项选择完毕后,会触发 finish 事件
    onFinish({ selectedOptions }) {
      this.showPicker = false
      this.fieldValue = selectedOptions.map(option => option.text).join('/')
    }
  }
}
</script>
posted @ 2023-05-11 14:46  战立标  阅读(1407)  评论(2编辑  收藏  举报