my-cascader.vue 9.48 KB
<template>
  <view class="my-cascader">
    <!-- 触发按钮/输入框 外部可自定义,这里做基础展示 -->
    <view class="cascader-trigger" @click="openPopup">
      <text class="trigger-text">{{ showLabel || placeholder }}</text>
      <up-icon name="arrow-right" class="trigger-icon"></up-icon>
    </view>

    <!-- 底层基于 up-popup 实现弹窗,保证关闭/遮罩正常 -->
    <up-popup
      :show="popupShow"
      mode="bottom"
      :popup="false"
      :mask="true"
      :closeable="true"
      :safe-area-inset-bottom="true"
      close-icon-color="#ffffff"
      :z-index="zIndex"
      :mask-close-able="maskCloseAble"
      @close="handlePopupClose"
    >
      <!-- 顶部标签栏 -->
      <view class="up-p-t-30 up-p-l-20 up-m-b-10" v-if="headerDirection == 'column'">
        <up-steps v-if="popupShow" dot direction="column" v-model:current="tabsIndex">
          <up-steps-item
            v-for="(item, index) in genTabsList"
            :key="index"
            @click="tabsIndex = index"
            :title="item.name"
          ></up-steps-item>
        </up-steps>
      </view>
      <view class="up-p-t-20 up-m-b-10" v-else>
        <up-tabs
          v-if="popupShow"
          :list="genTabsList"
          :scrollable="true"
          v-model:current="tabsIndex"
          @change="tabsChange"
          ref="tabs"
        ></up-tabs>
      </view>

      <!-- 级联内容区域 -->
      <view class="area-box">
        <view
          class="u-flex"
          :class="{ change: isChange }"
          :style="{ transform: optionsCols == 2 && isChange ? 'translateX(-33.3333333%)' : '' }"
        >
          <template v-for="(levelData, levelIndex) in levelList" :key="levelIndex">
            <view
              v-if="optionsCols == 2 || levelIndex == tabsIndex"
              class="area-item"
              :style="{ width: optionsCols == 2 ? '33.33333%' : '750rpx' }"
            >
              <view class="u-padding-10 u-bg-gray" style="height: 100%;">
                <scroll-view scroll-y style="height: 100%">
                  <up-cell-group
                    v-if="levelIndex === 0 || selectedValueIndexs[levelIndex - 1] !== undefined"
                  >
                    <up-cell
                      v-for="(item, index) in levelData"
                      :key="index"
                      :title="item[labelKey]"
                      :arrow="false"
                      :index="index"
                      @click="levelChange(levelIndex, index)"
                    >
                      <template #right-icon>
                        <up-icon
                          v-if="selectedValueIndexs[levelIndex] === index"
                          size="17"
                          name="checkbox-mark"
                        ></up-icon>
                      </template>
                    </up-cell>
                  </up-cell-group>
                </scroll-view>
              </view>
            </view>
          </template>
        </view>
      </view>

      <!-- 底部 取消 / 确定 按钮 -->
      <view class="u-cascader-action up-flex up-flex-between">
        <view class="u-padding-20 up-flex-fill">
          <up-button @click="handleCancel" type="default">取消</up-button>
        </view>
        <view class="u-padding-20 up-flex-fill">
          <up-button @click="handleConfirm" type="primary">确定</up-button>
        </view>
      </view>
    </up-popup>
  </view>
</template>

<script setup>
import { ref, watch, computed, defineProps, defineEmits, onMounted } from 'vue'

// ========== Props 定义 ==========
const props = defineProps({
  // 级联数据源
  data: {
    type: Array,
    default: () => []
  },
  // 双向绑定选中值(数组,对应每一级 value)【核心:用于默认选中、详情回显、编辑】
  modelValue: {
    type: Array,
    default: () => []
  },
  // 占位提示文字
  placeholder: {
    type: String,
    default: '请选择'
  },
  // 字段映射
  valueKey: {
    type: String,
    default: 'value'
  },
  labelKey: {
    type: String,
    default: 'label'
  },
  childrenKey: {
    type: String,
    default: 'children'
  },
  // 遮罩点击关闭
  maskCloseAble: {
    type: Boolean,
    default: true
  },
  // 弹窗层级
  zIndex: {
    type: [String, Number],
    default: 1075
  },
  // 头部布局 row / column
  headerDirection: {
    type: String,
    default: 'row'
  },
  // 列数 1 / 2
  optionsCols: {
    type: Number,
    default: 2
  }
})

// ========== 事件定义 ==========
const emit = defineEmits([
  'update:modelValue',
  'change',
  'confirm'
])

// ========== 内部状态 ==========
// 弹窗显示
const popupShow = ref(false)
// 当前标签页索引
const tabsIndex = ref(0)
// 每一级列表数据
const levelList = ref([])
// 每一级选中的索引
const selectedValueIndexs = ref([])
// 最终确认的值
const confirmValues = ref([])

// ========== 计算属性 ==========
const isChange = computed(() => tabsIndex.value > 1)

// 顶部标签文字
const genTabsList = computed(() => {
  let tabsList = [{ name: props.placeholder }]
  for (let i = 0; i < selectedValueIndexs.value.length; i++) {
    if (selectedValueIndexs.value[i] !== undefined && levelList.value[i]) {
      const selectedItem = levelList.value[i][selectedValueIndexs.value[i]]
      if (selectedItem) {
        tabsList[i] = { name: selectedItem[props.labelKey] }
        // 还有下级则追加占位
        if (i === selectedValueIndexs.value.length - 1 && selectedItem[props.childrenKey]?.length) {
          tabsList.push({ name: props.placeholder })
        }
      }
    }
  }
  return tabsList
})

// 输入框展示的拼接文本(三级拼接 label)
const showLabel = computed(() => {
  if (!props.modelValue.length || !levelList.value.length) return ''
  const labelArr = []
  selectedValueIndexs.value.forEach((idx, level) => {
    if (idx !== undefined && levelList.value[level]?.[idx]) {
      labelArr.push(levelList.value[level][idx][props.labelKey])
    }
  })
  return labelArr.join('/')
})

// ========== 核心方法 ==========
// 初始化级联数据
const initLevelList = () => {
  if (props.data?.length) {
    levelList.value = [props.data]
    selectedValueIndexs.value = []
  }
}

// 根据 modelValue 回显默认选中(详情/编辑初始化用)
const setDefaultValue = () => {
  const valArr = props.modelValue
  if (!valArr.length || !props.data.length) {
    selectedValueIndexs.value = []
    return
  }
  selectedValueIndexs.value = []
  let currentData = props.data

  for (let i = 0; i < valArr.length; i++) {
    const targetVal = valArr[i]
    const findIndex = currentData.findIndex(item => item[props.valueKey] === targetVal)
    if (findIndex === -1) break

    selectedValueIndexs.value.push(findIndex)
    // 下一级数据
    const nextChildren = currentData[findIndex][props.childrenKey]
    if (!nextChildren?.length) break
    currentData = nextChildren
    // 同步层级列表
    if (levelList.value.length <= i + 1) {
      levelList.value.push(nextChildren)
    } else {
      levelList.value[i + 1] = nextChildren
    }
  }
  // 定位到最后一级 tab
  tabsIndex.value = selectedValueIndexs.value.length
}

// 打开弹窗
const openPopup = () => {
  popupShow.value = true
}

// 弹窗关闭(遮罩/叉号触发)
const handlePopupClose = () => {
  popupShow.value = false
}

// 取消按钮
const handleCancel = () => {
  handlePopupClose()
}

// 确定按钮:回传选中值,支持 v-model
const handleConfirm = () => {
  // 组装选中 value 数组
  const res = []
  selectedValueIndexs.value.forEach((idx, level) => {
    if (idx !== undefined && levelList.value[level]?.[idx]) {
      res.push(levelList.value[level][idx][props.valueKey])
    }
  })
  confirmValues.value = res
  // 双向绑定更新
  emit('update:modelValue', res)
  emit('confirm', res)
  emit('change', res)
  handlePopupClose()
}

// 点击每一级选项
const levelChange = (levelIndex, index) => {
  selectedValueIndexs.value[levelIndex] = index
  // 清空后续层级选中
  for (let i = levelIndex + 1; i < selectedValueIndexs.value.length; i++) {
    selectedValueIndexs.value[i] = undefined
  }
  const currentItem = levelList.value[levelIndex][index]
  const nextChildren = currentItem[props.childrenKey]

  // 有下级,加载并切换 tab
  if (nextChildren?.length) {
    if (levelList.value.length <= levelIndex + 1) {
      levelList.value.push(nextChildren)
    } else {
      levelList.value[levelIndex + 1] = nextChildren
    }
    tabsIndex.value = levelIndex + 1
  }
}

// tab 切换
const tabsChange = () => {}

// ========== 监听:数据源 / 选中值变化(回显、编辑、详情自动刷新) ==========
watch(
  () => props.data,
  () => {
    initLevelList()
    setDefaultValue()
  },
  { immediate: true, deep: true }
)

watch(
  () => props.modelValue,
  () => {
    setDefaultValue()
  },
  { deep: true }
)

// 页面挂载初始化
onMounted(() => {
  initLevelList()
  setDefaultValue()
})
</script>

<style scoped lang="scss">
.cascader-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 10rpx;
  height: 70rpx;
  border: 1px solid #e5e5e5;
  border-radius: 8rpx;
  background: #fff;
}
.trigger-text {
  font-size: 28rpx;
  color: #333;
}
.trigger-icon {
  color: #999;
}

.area-box {
  width: 100%;
  overflow: hidden;
  height: 800rpx;
  > view {
    width: 150%;
    transition: transform 0.3s ease-in-out 0s;
    transform: translateX(0);
    &.change {}
  }
  .area-item {
    height: 800rpx;
  }
}

.u-cascader-action {
  border-top: 1px solid #eee;
}
</style>