基于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>

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,701評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,694評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,026評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,193評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,719評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,442評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,668評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,846評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,394評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380