基于vant自定义级联选择器-支持多选

<template>
  <div class="optimized-multi-cascader">
    <!-- 触发输入框 -->
    <van-field readonly clickable :value="displayText" placeholder="请选择" class="trigger-input" @click="showPicker = true">
      <template #right-icon>
        <van-badge v-if="selectedLeafNodes.length > 0" :content="selectedLeafNodes.length" />
      </template>
    </van-field>

    <!-- 级联选择器弹窗 -->
    <van-popup v-model:show="showPicker" round position="bottom" class="cascader-popup" :style="{ height: '70vh' }">
      <!-- 头部操作区 -->
      <div class="popup-header">
        <van-button type="default" size="small" plain @click="showPicker = false"> 取消 </van-button>
        <div class="header-title">{{ title }}</div>
        <van-button type="primary" size="small" @click="handleConfirm"> 确定({{ selectedLeafNodes.length }}) </van-button>
      </div>

      <!-- 级联面板容器 -->
      <div class="cascader-container">
        <!-- 三级面板 -->
        <div v-for="(column, level) in columns" :key="level" class="cascader-column" :style="{ width: getColumnWidth(level) }">
          {{ column }}
          <!-- 面板项虚拟滚动容器 -->
          <virtual-list :size="48" :remain="10" :data="column" class="virtual-list">
            <template #default="{ item }">
              <div
                class="cascader-item"
                :class="{
                  active: activeLevels[level] === item.value,
                  selected: getCheckState(item),
                  indeterminate: item.indeterminate
                }"
                @click="handleItemClick(item, level)"
              >
                <van-checkbox :model-value="getCheckState(item)" :indeterminate="item.indeterminate" @click.stop="toggleCheck(item)" />
                <span class="item-text" :title="item.text">
                  {{ truncateText(item.text, level) }}
                </span>
                <van-icon v-if="hasChildren(item)" name="arrow" class="arrow-icon" />
              </div>
            </template>
          </virtual-list>
        </div>
      </div>

      <!-- 已选标签展示区 -->
      <div v-if="selectedLeafNodes.length > 0" class="selected-tags-container">
        <div class="tags-header">
          <span class="tags-title">已选内容:</span>
          <van-button type="danger" size="mini" plain @click="clearAll"> 清空 </van-button>
        </div>
        <div class="tags-scroller">
          <van-tag v-for="value in selectedLeafNodes" :key="value" type="primary" size="medium" closeable @close="removeTag(value)" class="selected-tag">
            {{ getTagDisplayText(value) }}
          </van-tag>
        </div>
      </div>
    </van-popup>
  </div>
</template>

<script setup>
import { ref, computed, watch, nextTick } from "vue"
import { cloneDeep } from "lodash-es"
import VirtualList from "vue-virtual-scroll-list"

const props = defineProps({
  modelValue: {
    type: Array,
    default: () => []
  },
  options: {
    type: Array,
    required: true,
    validator: (value) => Array.isArray(value) && value.every(validateNode)
  },
  title: {
    type: String,
    default: "请选择"
  },
  maxDisplayTextLength: {
    type: Number,
    default: 20
  }
})

const emit = defineEmits(["update:modelValue"])

// 数据验证
function validateNode(node) {
  return (node.text && node.value && !node.children) || (Array.isArray(node.children) && node.children.every(validateNode))
}

// 响应式状态
const showPicker = ref(false)
const columns = ref([[], [], []])
const activeLevels = ref([null, null, null])
const processedData = ref([])
const selectedLeafNodes = ref([])

// 初始化处理数据
const processData = (data) => {
  return data.map((item) => ({
    ...item,
    indeterminate: false,
    checked: false,
    children: item.children ? processData(item.children) : null,
    // 添加路径信息用于显示
    path: item.path || [item.text]
  }))
}

// 处理子节点路径
const processPaths = (nodes, parentPath = []) => {
  return nodes.map((node) => {
    const currentPath = [...parentPath, node.text]
    return {
      ...node,
      path: currentPath,
      children: node.children ? processPaths(node.children, currentPath) : null
    }
  })
}

// 递归查找节点
const findNode = (nodes, value) => {
  for (const node of nodes) {
    if (node.value === value) return node
    if (node.children) {
      const found = findNode(node.children, value)
      if (found) return found
    }
  }
  return null
}

// 更新选中状态
const updateCheckStatus = (node) => {
  if (!node.children) {
    node.checked = selectedLeafNodes.value.includes(node.value)
    return
  }

  let allChecked = true
  let someChecked = false

  node.children.forEach((child) => {
    updateCheckStatus(child)
    if (!child.checked) allChecked = false
    if (child.checked || child.indeterminate) someChecked = true
  })

  node.checked = allChecked
  node.indeterminate = !allChecked && someChecked
}

// 切换选中状态
const toggleCheck = (node) => {
  const shouldCheck = !(node.checked || node.indeterminate)
  const toggleChildren = (currentNode, state) => {
    if (currentNode.children) {
      currentNode.children.forEach((child) => toggleChildren(child, state))
    } else {
      const index = selectedLeafNodes.value.indexOf(currentNode.value)
      if (state && index === -1) {
        selectedLeafNodes.value.push(currentNode.value)
      } else if (!state && index !== -1) {
        selectedLeafNodes.value.splice(index, 1)
      }
    }
  }

  toggleChildren(node, shouldCheck)
  processedData.value.forEach(updateCheckStatus)
}

// 处理项点击
const handleItemClick = async (item, level) => {
  activeLevels.value[level] = item.value

  // 更新级联面板
  const newColumns = columns.value.slice(0, level + 1)
  if (item.children) {
    newColumns[level + 1] = item.children
    if (level < 1) {
      newColumns[level + 2] = []
    }
  }
  columns.value = newColumns

  await nextTick()
}

// 确认选择
const handleConfirm = () => {
  emit("update:modelValue", [...selectedLeafNodes.value])
  showPicker.value = false
}

// 显示文本优化
const displayText = computed(() => {
  if (selectedLeafNodes.value.length === 0) return ""

  const displayItems = selectedLeafNodes.value.slice(0, 3).map(getTagDisplayText)
  if (selectedLeafNodes.value.length > 3) {
    displayItems.push(`+${selectedLeafNodes.value.length - 3}`)
  }

  return displayItems.join(", ")
})

// 获取标签显示文本
const getTagDisplayText = (value) => {
  const node = findNode(processedData.value, value)
  if (!node) return ""

  // 显示完整路径
  return node.path.slice(-3).join("/")
}

// 移除标签
const removeTag = (value) => {
  const index = selectedLeafNodes.value.indexOf(value)
  if (index !== -1) {
    selectedLeafNodes.value.splice(index, 1)
    processedData.value.forEach(updateCheckStatus)
    emit("update:modelValue", [...selectedLeafNodes.value])
  }
}

// 清空所有选择
const clearAll = () => {
  selectedLeafNodes.value = []
  processedData.value.forEach(updateCheckStatus)
  emit("update:modelValue", [])
}

// 文本截断处理
const truncateText = (text, level) => {
  const maxLength = props.maxDisplayTextLength - level * 3
  return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text
}

// 列宽计算
const getColumnWidth = (level) => {
  const baseWidth = [35, 30, 35] // 三级列宽百分比
  return `${baseWidth[level]}%`
}

// 初始化处理数据
processedData.value = processPaths(processData(cloneDeep(props.options)))
columns.value[0] = processedData.value

// 监听外部值变化
watch(
  () => props.modelValue,
  (newVal) => {
    selectedLeafNodes.value = [...newVal]
    processedData.value.forEach(updateCheckStatus)
  },
  { immediate: true }
)

// 辅助方法
const hasChildren = (item) => item.children && item.children.length > 0
const getCheckState = (item) => {
  if (item.children) return item.checked
  return selectedLeafNodes.value.includes(item.value)
}
</script>

<style scoped>
.optimized-multi-cascader {
  --active-color: #1989fa;
  --hover-bg: #f5f7fa;
  --border-color: #ebedf0;
  --tag-bg: #e8f4ff;
  --text-color: #323233;
  --secondary-text: #969799;
}

.trigger-input {
  background: #f7f8fa;
  border-radius: 8px;
  cursor: pointer;
}

.popup-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 12px 16px;
  border-bottom: 1px solid var(--border-color);
}

.header-title {
  font-weight: 500;
  font-size: 16px;
  color: var(--text-color);
}

.cascader-container {
  display: flex;
  height: calc(70vh - 132px);
  overflow-x: auto;
  border-bottom: 1px solid var(--border-color);
}

.cascader-column {
  flex-shrink: 0;
  height: 100%;
  border-right: 1px solid var(--border-color);
}

.virtual-list {
  height: 100%;
  overflow-y: auto;
}

.cascader-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  cursor: pointer;
  transition: all 0.2s;
  position: relative;
}

.cascader-item:hover {
  background: var(--hover-bg);
}

.cascader-item.active {
  background-color: rgba(25, 137, 250, 0.1);
}

.cascader-item.selected {
  background-color: rgba(25, 137, 250, 0.05);
}

.cascader-item.indeterminate::before {
  content: "";
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 60%;
  background-color: var(--active-color);
  border-radius: 2px;
}

.item-text {
  flex: 1;
  margin-left: 8px;
  font-size: 14px;
  color: var(--text-color);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.arrow-icon {
  margin-left: 8px;
  color: var(--secondary-text);
  font-size: 14px;
}

.selected-tags-container {
  padding: 12px 16px;
}

.tags-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 8px;
}

.tags-title {
  font-size: 14px;
  color: var(--secondary-text);
}

.tags-scroller {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  max-height: 80px;
  overflow-y: auto;
  padding: 4px 0;
}

.selected-tag {
  background: var(--tag-bg);
  color: var(--active-color);
  border: none;
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: transform 0.2s;
}

.selected-tag:hover {
  transform: translateY(-2px);
}
</style>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容