使用 uni-popup 实现数据选择器Data-Picker

使用 uni-popup 实现数据选择器Data-Picker

uni-popup 作为底部弹出面板,内部放置可滚动的选择列表,点击选项后关闭弹窗并回传数据。

1.单选数据选择
Vue 复制代码
<template>
  <view class="page">
    <!-- 触发区域 -->
    <view class="selector" @click="openPicker">
      <text class="selector-label">选择城市</text>
      <text class="selector-value" :class="{ placeholder: !selectedCity }">
        {{ selectedCity || '请选择城市' }}
      </text>
      <uni-icons type="right" size="16" color="#999" />
    </view>

    <!-- 弹窗 -->
    <uni-popup ref="popup" type="bottom" :is-mask-click="true" @change="onPopupChange">
      <view class="popup-content">
        <!-- 标题栏 -->
        <view class="popup-header">
          <text class="popup-title">选择城市</text>
          <view class="popup-close" @click="closePicker">
            <uni-icons type="closeempty" size="20" color="#999" />
          </view>
        </view>

        <!-- 搜索框 -->
        <view class="search-bar">
          <uni-icons type="search" size="16" color="#999" />
          <input
            class="search-input"
            v-model="keyword"
            placeholder="搜索..."
            :adjust-position="false"
          />
          <view v-if="keyword" class="search-clear" @click="keyword = ''">
            <uni-icons type="clear" size="16" color="#ccc" />
          </view>
        </view>

        <!-- 选项列表 -->
        <scroll-view class="option-list" scroll-y :scroll-into-view="scrollTarget">
          <view
            v-for="item in filteredList"
            :key="item.value"
            class="option-item"
            :class="{ active: selectedCity === item.label }"
            @click="onSelect(item)"
          >
            <text class="option-text">{{ item.label }}</text>
            <uni-icons
              v-if="selectedCity === item.label"
              type="checkmarkempty"
              size="18"
              color="#007AFF"
            />
          </view>

          <!-- 空状态 -->
          <view v-if="filteredList.length === 0" class="empty-state">
            <text class="empty-text">没有匹配的选项</text>
          </view>
        </scroll-view>
      </view>
    </uni-popup>
  </view>
</template>

<script>
export default {
  data() {
    return {
      selectedCity: '',
      keyword: '',
      scrollTarget: '',
      cityList: [
        { label: '北京', value: 'beijing' },
        { label: '上海', value: 'shanghai' },
        { label: '广州', value: 'guangzhou' },
        { label: '深圳', value: 'shenzhen' },
        { label: '杭州', value: 'hangzhou' },
        { label: '成都', value: 'chengdu' },
        { label: '武汉', value: 'wuhan' },
        { label: '南京', value: 'nanjing' },
        { label: '西安', value: 'xian' },
        { label: '重庆', value: 'chongqing' },
        { label: '苏州', value: 'suzhou' },
        { label: '天津', value: 'tianjin' },
        { label: '长沙', value: 'changsha' },
        { label: '郑州', value: 'zhengzhou' },
        { label: '青岛', value: 'qingdao' }
      ]
    }
  },

  computed: {
    filteredList() {
      if (!this.keyword) return this.cityList
      const kw = this.keyword.toLowerCase()
      return this.cityList.filter(item =>
        item.label.toLowerCase().includes(kw) ||
        item.value.toLowerCase().includes(kw)
      )
    }
  },

  methods: {
    openPicker() {
      this.keyword = ''
      this.$refs.popup.open()
    },

    closePicker() {
      this.$refs.popup.close()
    },

    onSelect(item) {
      this.selectedCity = item.label
      this.$refs.popup.close()
      this.$emit('change', item)
      console.log('已选择:', item)
    },

    onPopupChange(e) {
      // e.show: true=打开, false=关闭
      if (!e.show) {
        this.keyword = ''
      }
    }
  }
}
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f5f5;
  padding: 40rpx 32rpx;
}

/* ── 触发选择器 ── */
.selector {
  display: flex;
  align-items: center;
  background: #fff;
  border-radius: 16rpx;
  padding: 28rpx 24rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.selector-label {
  font-size: 28rpx;
  color: #333;
  margin-right: 24rpx;
  white-space: nowrap;
}
.selector-value {
  flex: 1;
  font-size: 28rpx;
  color: #333;
  text-align: right;
}
.selector-value.placeholder {
  color: #bbb;
}

/* ── 弹窗内容 ── */
.popup-content {
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  max-height: 75vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.popup-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 32rpx 32rpx 16rpx;
  border-bottom: 1rpx solid #f0f0f0;
}
.popup-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #222;
}
.popup-close {
  padding: 8rpx;
}

/* ── 搜索栏 ── */
.search-bar {
  display: flex;
  align-items: center;
  margin: 16rpx 32rpx 8rpx;
  padding: 16rpx 20rpx;
  background: #f5f6f8;
  border-radius: 12rpx;
}
.search-input {
  flex: 1;
  font-size: 26rpx;
  color: #333;
  margin-left: 12rpx;
}
.search-clear {
  padding: 4rpx;
}

/* ── 选项列表 ── */
.option-list {
  flex: 1;
  padding: 8rpx 0 calc(env(safe-area-inset-bottom) + 16rpx);
  max-height: 55vh;
}
.option-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28rpx 32rpx;
  transition: background 0.15s;
}
.option-item:active {
  background: #f5f5f5;
}
.option-item.active {
  background: #f0f7ff;
}
.option-text {
  font-size: 30rpx;
  color: #333;
}
.option-item.active .option-text {
  color: #007AFF;
  font-weight: 500;
}

/* ── 空状态 ── */
.empty-state {
  padding: 80rpx 0;
  text-align: center;
}
.empty-text {
  font-size: 26rpx;
  color: #bbb;
}
</style>
2.多选数据选择

在单选基础上,改用数组维护选中状态,增加"确定"按钮:

vue 复制代码
<template>
  <view class="page">
    <view class="selector" @click="openPicker">
      <text class="selector-label">选择标签</text>
      <view class="tags-wrap">
        <view v-if="selectedTags.length === 0" class="placeholder">请选择标签</view>
        <view v-for="tag in selectedTags" :key="tag" class="tag-chip">{{ tag }}</view>
      </view>
      <uni-icons type="right" size="16" color="#999" />
    </view>

    <uni-popup ref="popup" type="bottom" :is-mask-click="true">
      <view class="popup-content">
        <view class="popup-header">
          <text class="popup-action" @click="closePicker">取消</text>
          <text class="popup-title">选择标签</text>
          <text class="popup-action confirm" @click="confirmMulti">确定</text>
        </view>

        <!-- 已选数量提示 -->
        <view class="selected-count" v-if="tempSelected.length > 0">
          已选 {{ tempSelected.length }} 项
        </view>

        <scroll-view class="option-list" scroll-y>
          <view
            v-for="item in tagList"
            :key="item.value"
            class="option-item"
            :class="{ active: tempSelected.includes(item.value) }"
            @click="toggleItem(item.value)"
          >
            <view class="option-left">
              <view class="checkbox" :class="{ checked: tempSelected.includes(item.value) }">
                <uni-icons
                  v-if="tempSelected.includes(item.value)"
                  type="checkmarkempty"
                  size="14"
                  color="#fff"
                />
              </view>
              <text class="option-text">{{ item.label }}</text>
            </view>
          </view>
        </scroll-view>
      </view>
    </uni-popup>
  </view>
</template>

<script>
export default {
  data() {
    return {
      selectedTags: [],      // 最终选中
      tempSelected: [],      // 弹窗内临时选中
      tagList: [
        { label: '前端开发', value: 'frontend' },
        { label: '后端开发', value: 'backend' },
        { label: 'UI 设计', value: 'ui' },
        { label: '产品经理', value: 'pm' },
        { label: '数据分析', value: 'data' },
        { label: '人工智能', value: 'ai' },
        { label: '运维部署', value: 'devops' },
        { label: '测试质量', value: 'qa' }
      ]
    }
  },

  methods: {
    openPicker() {
      // 打开时拷贝一份当前选中状态,用于临时编辑
      this.tempSelected = [...this.selectedTags]
      this.$refs.popup.open()
    },

    closePicker() {
      this.$refs.popup.close()
    },

    toggleItem(value) {
      const idx = this.tempSelected.indexOf(value)
      if (idx > -1) {
        this.tempSelected.splice(idx, 1)
      } else {
        this.tempSelected.push(value)
      }
    },

    confirmMulti() {
      this.selectedTags = [...this.tempSelected]
      this.$refs.popup.close()

      // 回传完整选中对象
      const result = this.tagList.filter(item =>
        this.selectedTags.includes(item.value)
      )
      this.$emit('change', result)
      console.log('已选择:', result)
    }
  }
}
</script>

<style scoped>
/* 复用上面的基础样式,额外增加: */

.tags-wrap {
  flex: 1;
  display: flex;
  flex-wrap: wrap;
  gap: 12rpx;
  justify-content: flex-end;
  margin-right: 16rpx;
}
.tag-chip {
  font-size: 22rpx;
  color: #007AFF;
  background: #ecf5ff;
  border-radius: 8rpx;
  padding: 6rpx 16rpx;
}

.popup-action {
  font-size: 28rpx;
  color: #999;
  padding: 8rpx;
}
.popup-action.confirm {
  color: #007AFF;
  font-weight: 600;
}

.selected-count {
  font-size: 24rpx;
  color: #007AFF;
  padding: 12rpx 32rpx 0;
}

.option-left {
  display: flex;
  align-items: center;
  gap: 20rpx;
}

.checkbox {
  width: 40rpx;
  height: 40rpx;
  border: 3rpx solid #ddd;
  border-radius: 8rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s;
}
.checkbox.checked {
  background: #007AFF;
  border-color: #007AFF;
}
</style>
3. 封装成通用组件 PopupPicker.vue

把以上逻辑抽成可复用组件,通过 props 控制行为:

vue 复制代码
<!-- components/PopupPicker.vue -->
<template>
  <view>
    <!-- 插槽:自定义触发器 -->
    <view @click="open">
      <slot :selected="displayLabel">
        <view class="default-trigger">
          <text :class="{ placeholder: !displayLabel }">
            {{ displayLabel || placeholder }}
          </text>
          <uni-icons type="right" size="16" color="#bbb" />
        </view>
      </slot>
    </view>

    <uni-popup ref="popup" type="bottom" :is-mask-click="maskClose" @change="$emit('popup-change', $event)">
      <view class="picker-panel">
        <!-- 头部 -->
        <view class="picker-header">
          <text v-if="multiple" class="picker-btn" @click="close">取消</text>
          <text class="picker-title">{{ title }}</text>
          <text v-if="multiple" class="picker-btn confirm" @click="confirm">确定</text>
          <view v-else style="width: 80rpx" />
        </view>

        <!-- 搜索 -->
        <view v-if="searchable" class="picker-search">
          <uni-icons type="search" size="16" color="#999" />
          <input class="picker-search-input" v-model="keyword" placeholder="搜索..." />
        </view>

        <!-- 列表 -->
        <scroll-view class="picker-list" scroll-y>
          <view
            v-for="item in filtered"
            :key="item[valueKey]"
            class="picker-item"
            :class="{ active: isActive(item[valueKey]) }"
            @click="handleTap(item)"
          >
            <view v-if="multiple" class="cb" :class="{ on: isActive(item[valueKey]) }">
              <uni-icons v-if="isActive(item[valueKey])" type="checkmarkempty" size="13" color="#fff" />
            </view>
            <text class="picker-item-text">{{ item[labelKey] }}</text>
            <uni-icons v-if="!multiple && isActive(item[valueKey])" type="checkmarkempty" size="18" color="#007AFF" />
          </view>
          <view v-if="filtered.length === 0" class="picker-empty">
            <text>暂无数据</text>
          </view>
        </scroll-view>
      </view>
    </uni-popup>
  </view>
</template>

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

  props: {
    // 数据源
    options: { type: Array, default: () => [] },
    // 字段映射
    labelKey: { type: String, default: 'label' },
    valueKey: { type: String, default: 'value' },
    // 标题
    title: { type: String, default: '请选择' },
    // 占位文字
    placeholder: { type: String, default: '请选择' },
    // 是否多选
    multiple: { type: Boolean, default: false },
    // 是否可搜索
    searchable: { type: Boolean, default: false },
    // 点击遮罩关闭
    maskClose: { type: Boolean, default: true },
    // 已选中的值(v-model)
    value: { type: [String, Number, Array], default: '' },
    // 最大选择数(仅多选)
    max: { type: Number, default: Infinity }
  },

  data() {
    return {
      keyword: '',
      tempValue: ''
    }
  },

  computed: {
    filtered() {
      if (!this.keyword) return this.options
      const kw = this.keyword.toLowerCase()
      return this.options.filter(item =>
        String(item[this.labelKey]).toLowerCase().includes(kw)
      )
    },

    displayLabel() {
      if (this.multiple && Array.isArray(this.value)) {
        return this.options
          .filter(item => this.value.includes(item[this.valueKey]))
          .map(item => item[this.labelKey])
          .join('、')
      }
      const found = this.options.find(item => item[this.valueKey] === this.value)
      return found ? found[this.labelKey] : ''
    }
  },

  methods: {
    open() {
      this.keyword = ''
      this.tempValue = this.multiple ? [...(this.value || [])] : this.value
      this.$refs.popup.open()
    },

    close() {
      this.$refs.popup.close()
    },

    isActive(val) {
      return this.multiple
        ? Array.isArray(this.tempValue) && this.tempValue.includes(val)
        : this.tempValue === val
    },

    handleTap(item) {
      const val = item[this.valueKey]
      if (this.multiple) {
        const idx = this.tempValue.indexOf(val)
        if (idx > -1) {
          this.tempValue.splice(idx, 1)
        } else if (this.tempValue.length < this.max) {
          this.tempValue.push(val)
        }
      } else {
        this.tempValue = val
        this.$emit('input', val)
        this.$emit('change', item)
        this.close()
      }
    },

    confirm() {
      this.$emit('input', [...this.tempValue])
      const selected = this.options.filter(item =>
        this.tempValue.includes(item[this.valueKey])
      )
      this.$emit('change', selected)
      this.close()
    }
  }
}
</script>

<style scoped>
.picker-panel {
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  max-height: 75vh;
  display: flex;
  flex-direction: column;
}
.picker-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28rpx 32rpx 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
}
.picker-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #222;
}
.picker-btn {
  font-size: 28rpx;
  color: #999;
  min-width: 80rpx;
  text-align: center;
}
.picker-btn.confirm {
  color: #007AFF;
  font-weight: 600;
}

.picker-search {
  display: flex;
  align-items: center;
  margin: 16rpx 32rpx;
  padding: 14rpx 20rpx;
  background: #f5f6f8;
  border-radius: 12rpx;
}
.picker-search-input {
  flex: 1;
  font-size: 26rpx;
  margin-left: 12rpx;
}

.picker-list {
  flex: 1;
  max-height: 50vh;
  padding-bottom: env(safe-area-inset-bottom);
}
.picker-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 26rpx 32rpx;
}
.picker-item:active {
  background: #f8f8f8;
}
.picker-item.active {
  background: #f0f7ff;
}
.picker-item-text {
  flex: 1;
  font-size: 30rpx;
  color: #333;
}
.picker-item.active .picker-item-text {
  color: #007AFF;
}

.cb {
  width: 38rpx;
  height: 38rpx;
  border: 2rpx solid #ddd;
  border-radius: 8rpx;
  margin-right: 20rpx;
  display: flex;
  align-items: center;
  justify-content: center;
}
.cb.on {
  background: #007AFF;
  border-color: #007AFF;
}

.default-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #fff;
  border-radius: 16rpx;
  padding: 28rpx 24rpx;
  font-size: 28rpx;
  color: #333;
}
.default-trigger .placeholder {
  color: #bbb;
}

.picker-empty {
  padding: 80rpx 0;
  text-align: center;
  font-size: 26rpx;
  color: #bbb;
}
</style>
4. 兼容级联选择
vue 复制代码
<!-- components/PopupPicker.vue -->
<template>
  <view>
    <!-- 触发器插槽 -->
    <view @click="open">
      <slot :selected="displayLabel" :selectedPath="displayPath">
        <view class="trigger">
          <text class="trigger-label" v-if="label">{{ label }}</text>
          <text class="trigger-value" :class="{ placeholder: !displayLabel }">
            {{ displayLabel || placeholder }}
          </text>
          <uni-icons type="right" size="16" color="#bbb" />
        </view>
      </slot>
    </view>

    <!-- 弹窗 -->
    <uni-popup ref="popup" type="bottom" :is-mask-click="maskClose">
      <view class="panel">
        <!-- 头部 -->
        <view class="panel-header">
          <text class="header-btn" @click="close">取消</text>
          <text class="header-title">{{ title }}</text>
          <text
            class="header-btn confirm"
            :class="{ disabled: !canConfirm }"
            @click="confirm"
          >
            {{ multiple ? '确定' : '' }}
          </text>
        </view>

        <!-- 面包屑标签栏(多级时显示) -->
        <view class="breadcrumb" v-if="isTree">
          <view
            v-for="(tab, idx) in tabs"
            :key="idx"
            class="crumb"
            :class="{ active: currentLevel === idx }"
            @click="switchLevel(idx)"
          >
            <text class="crumb-text">{{ tab }}</text>
          </view>
          <!-- 当前级未选时的占位标签 -->
          <view class="crumb placeholder-crumb" v-if="!isLastLevel">
            <text class="crumb-text">请选择</text>
          </view>
        </view>

        <!-- 搜索栏 -->
        <view class="search-bar" v-if="searchable">
          <uni-icons type="search" size="16" color="#999" />
          <input
            class="search-input"
            v-model="keyword"
            placeholder="搜索..."
            :adjust-position="false"
          />
          <view v-if="keyword" @click="keyword = ''">
            <uni-icons type="clear" size="16" color="#ccc" />
          </view>
        </view>

        <!-- 选项列表 -->
        <scroll-view class="list" scroll-y scroll-with-animation>
          <view
            v-for="item in displayList"
            :key="item[valueKey]"
            class="list-item"
            :class="{ active: isItemActive(item) }"
            @click="handleTap(item)"
          >
            <view class="item-left">
              <!-- 多选模式复选框 -->
              <view v-if="showCheckbox(item)" class="checkbox" :class="{ on: isItemActive(item) }">
                <uni-icons v-if="isItemActive(item)" type="checkmarkempty" size="13" color="#fff" />
              </view>
              <text class="item-text">{{ item[labelKey] }}</text>
            </view>

            <view class="item-right">
              <!-- 单选勾选 -->
              <uni-icons
                v-if="!multiple && !hasChildren(item) && isItemActive(item)"
                type="checkmarkempty"
                size="18"
                color="#007AFF"
              />
              <!-- 子级箭头 -->
              <view v-if="hasChildren(item)" class="child-badge">
                <text class="child-count">{{ item.children.length }}项</text>
                <uni-icons type="right" size="14" color="#ccc" />
              </view>
            </view>
          </view>

          <!-- 空状态 -->
          <view v-if="displayList.length === 0" class="empty">
            <text class="empty-text">暂无数据</text>
          </view>
        </scroll-view>

        <!-- 底部已选摘要(多选模式) -->
        <view class="footer" v-if="multiple && tempSelected.length > 0">
          <view class="footer-tags">
            <view
              v-for="(tag, idx) in selectedDisplayList"
              :key="idx"
              class="footer-tag"
            >
              <text class="footer-tag-text">{{ tag }}</text>
              <view @click.stop="removeSelected(idx)">
                <uni-icons type="clear" size="14" color="#999" />
              </view>
            </view>
          </view>
          <view class="footer-count">
            <text class="count-text">已选 {{ tempSelected.length }}{{ max < Infinity ? '/' + max : '' }}</text>
          </view>
        </view>
      </view>
    </uni-popup>
  </view>
</template>

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

  props: {
    // ── 数据 ──
    options: { type: Array, default: () => [] },
    labelKey: { type: String, default: 'label' },
    valueKey: { type: String, default: 'value' },
    childrenKey: { type: String, default: 'children' },

    // ── 显示 ──
    title: { type: String, default: '请选择' },
    label: { type: String, default: '' },
    placeholder: { type: String, default: '请选择' },
    searchable: { type: Boolean, default: false },
    maskClose: { type: Boolean, default: true },

    // ── 选择模式 ──
    multiple: { type: Boolean, default: false },
    max: { type: Number, default: Infinity },

    // ── 多级控制 ──
    // 是否允许选择非叶子节点(有子级的节点)
    selectParent: { type: Boolean, default: false },
    // 固定选择到第几级(从0开始),默认自动到叶子节点
    maxLevel: { type: Number, default: -1 },

    // ── v-model ──
    // 单选: String/Number(叶子节点的 value)
    // 多选: Array
    // 级联回传路径: Array(从根到叶子的 value 路径)
    value: { type: [String, Number, Array], default: '' },

    // ── 返回模式 ──
    // 'leaf'   - 返回叶子节点的 value(默认)
    // 'path'   - 返回整条路径的 value 数组 [省, 市, 区]
    // 'object' - 返回完整路径对象数组
    resultType: { type: String, default: 'leaf' }
  },

  data() {
    return {
      keyword: '',
      currentLevel: 0,           // 当前浏览的层级
      pathStack: [],              // 已选的祖先节点栈 [{item, children}]
      tempSelected: [],           // 多选临时存储
      tempPath: []                // 单选临时路径
    }
  },

  computed: {
    // 判断数据是否为树结构
    isTree() {
      if (!this.options.length) return false
      return this.options.some(item =>
        item[this.childrenKey] && item[this.childrenKey].length > 0
      ) || this.pathStack.length > 0
    },

    // 当前展示的列表
    currentChildren() {
      if (this.pathStack.length === 0) return this.options
      const last = this.pathStack[this.pathStack.length - 1]
      return last.item[this.childrenKey] || []
    },

    // 搜索过滤后的列表
    displayList() {
      if (!this.keyword) return this.currentChildren
      const kw = this.keyword.toLowerCase()
      return this.currentChildren.filter(item =>
        String(item[this.labelKey]).toLowerCase().includes(kw)
      )
    },

    // 是否最后一级
    isLastLevel() {
      if (this.maxLevel >= 0) return this.currentLevel >= this.maxLevel
      // 当前列表没有节点有 children,视为最后一级
      return !this.currentChildren.some(item =>
        item[this.childrenKey] && item[this.childrenKey].length > 0
      )
    },

    // 面包屑标签
    tabs() {
      return this.pathStack.map(entry => entry.item[this.labelKey])
    },

    // 显示标签(触发器上显示的文字)
    displayLabel() {
      if (this.multiple) {
        // 多选模式:拼接所有选中项
        if (!this.tempSelected.length && !this.value?.length) return ''
        const arr = this.tempSelected.length ? this.tempSelected : (this.value || [])
        return arr.map(v => this.findLabelByValue(v)).filter(Boolean).join('、')
      }

      // 单选模式:根据 resultType 取最后一级或全路径
      if (this.resultType === 'path' && Array.isArray(this.value)) {
        return this.value.map(v => this.findLabelByValue(v)).filter(Boolean).join(' / ')
      }

      // 默认取叶子标签
      const label = this.findLabelByValue(this.value)
      return label || ''
    },

    // 完整路径文本
    displayPath() {
      if (this.tempPath.length) {
        return this.tempPath.map(i => i[this.labelKey]).join(' / ')
      }
      if (Array.isArray(this.value)) {
        return this.value.map(v => this.findLabelByValue(v)).join(' / ')
      }
      return this.displayLabel
    },

    // 多选底部展示
    selectedDisplayList() {
      return this.tempSelected
        .map(v => this.findLabelByValue(v))
        .filter(Boolean)
    },

    // 是否可确认
    canConfirm() {
      if (!this.multiple) return true
      return this.tempSelected.length > 0
    }
  },

  methods: {
    // ════════ 打开/关闭 ════════
    open() {
      this.keyword = ''
      this.currentLevel = 0
      this.pathStack = []

      // 初始化临时选中状态
      if (this.multiple) {
        this.tempSelected = Array.isArray(this.value) ? [...this.value] : []
      } else {
        // 单选:尝试还原路径
        this.tempPath = this.reconstructPath(this.value)
      }

      this.$refs.popup.open()
    },

    close() {
      this.$refs.popup.close()
    },

    // ════════ 点击选项 ════════
    handleTap(item) {
      const hasKids = this.hasChildren(item)
      const isLeafLevel = this.isLastLevel || this.selectParent

      // 有子级 且 不是强制最后一级 且 未设置 selectParent → 进入下一级
      if (hasKids && !isLeafLevel) {
        this.enterChildren(item)
        return
      }

      // 可以选中
      if (this.multiple) {
        this.toggleMulti(item)
      } else {
        this.selectSingle(item)
      }
    },

    // 进入子级
    enterChildren(item) {
      this.keyword = ''
      this.pathStack.push({
        item,
        children: item[this.childrenKey]
      })
      this.currentLevel = this.pathStack.length
    },

    // 单选
    selectSingle(item) {
      const path = [...this.pathStack.map(e => e.item), item]

      if (this.resultType === 'path') {
        const values = path.map(i => i[this.valueKey])
        this.$emit('input', values)
        this.$emit('change', { values, path, item })
      } else if (this.resultType === 'object') {
        this.$emit('input', path.map(i => ({
          [this.labelKey]: i[this.labelKey],
          [this.valueKey]: i[this.valueKey]
        })))
        this.$emit('change', { path, item })
      } else {
        this.$emit('input', item[this.valueKey])
        this.$emit('change', item)
      }

      this.close()
    },

    // 多选切换
    toggleMulti(item) {
      const val = item[this.valueKey]
      const idx = this.tempSelected.indexOf(val)
      if (idx > -1) {
        this.tempSelected.splice(idx, 1)
      } else if (this.tempSelected.length < this.max) {
        this.tempSelected.push(val)
      }
    },

    // 多选确认
    confirm() {
      if (!this.multiple || !this.canConfirm) return

      this.$emit('input', [...this.tempSelected])

      const selected = this.tempSelected.map(v => this.findItemByValue(v)).filter(Boolean)
      this.$emit('change', selected)
      this.close()
    },

    // ════════ 面包屑导航 ════════
    switchLevel(idx) {
      if (idx >= this.pathStack.length) return
      this.keyword = ''
      this.pathStack = this.pathStack.slice(0, idx)
      this.currentLevel = idx
    },

    // ════════ 底部已选移除 ════════
    removeSelected(idx) {
      this.tempSelected.splice(idx, 1)
    },

    // ════════ 工具方法 ════════
    hasChildren(item) {
      return item[this.childrenKey] && item[this.childrenKey].length > 0
    },

    isItemActive(item) {
      const val = item[this.valueKey]
      if (this.multiple) {
        return this.tempSelected.includes(val)
      }
      // 单选:比较 value
      if (this.resultType === 'path' && Array.isArray(this.value)) {
        return this.value[this.currentLevel] === val
      }
      return this.value === val
    },

    showCheckbox(item) {
      if (!this.multiple) return false
      // 最后一级或允许选父级时显示
      return this.isLastLevel || this.selectParent || !this.hasChildren(item)
    },

    // 全局查找 value 对应的 label
    findLabelByValue(value) {
      const item = this.findItemByValue(value)
      return item ? item[this.labelKey] : ''
    },

    // 递归查找节点
    findItemByValue(value, list) {
      list = list || this.options
      for (const item of list) {
        if (item[this.valueKey] === value) return item
        if (item[this.childrenKey]) {
          const found = this.findItemByValue(value, item[this.childrenKey])
          if (found) return found
        }
      }
      return null
    },

    // 根据叶子 value 反查完整路径
    reconstructPath(value) {
      if (!value) return []
      const path = []
      const search = (list) => {
        for (const item of list) {
          path.push(item)
          if (item[this.valueKey] === value) return true
          if (item[this.childrenKey] && search(item[this.childrenKey])) return true
          path.pop()
        }
        return false
      }
      search(this.options)
      return path
    }
  }
}
</script>

<style scoped>
/* ════════ 触发器 ════════ */
.trigger {
  display: flex;
  align-items: center;
  background: #fff;
  border-radius: 16rpx;
  padding: 28rpx 24rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.trigger-label {
  font-size: 28rpx;
  color: #333;
  margin-right: 24rpx;
  white-space: nowrap;
}
.trigger-value {
  flex: 1;
  text-align: right;
  font-size: 28rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.trigger-value.placeholder {
  color: #bbb;
}

/* ════════ 面板 ════════ */
.panel {
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28rpx 32rpx 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
  flex-shrink: 0;
}
.header-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #222;
}
.header-btn {
  font-size: 28rpx;
  color: #999;
  min-width: 80rpx;
  text-align: center;
}
.header-btn.confirm {
  color: #007AFF;
  font-weight: 600;
}
.header-btn.confirm.disabled {
  color: #ccc;
}
.header-btn.confirm:empty {
  visibility: hidden;
}

/* ════════ 面包屑 ════════ */
.breadcrumb {
  display: flex;
  align-items: center;
  padding: 20rpx 32rpx 0;
  flex-shrink: 0;
  flex-wrap: wrap;
  gap: 8rpx;
}
.crumb {
  padding: 10rpx 24rpx;
  border-radius: 24rpx;
  background: #f5f6f8;
  transition: all 0.2s;
}
.crumb.active {
  background: #007AFF;
}
.crumb-text {
  font-size: 24rpx;
  color: #666;
}
.crumb.active .crumb-text {
  color: #fff;
  font-weight: 500;
}
.placeholder-crumb {
  background: transparent;
  border: 1rpx dashed #ddd;
}
.placeholder-crumb .crumb-text {
  color: #bbb;
}
.crumb + .crumb::before,
.crumb + .placeholder-crumb::before {
  content: '';
  display: inline-block;
  width: 0;
  height: 0;
}
/* 分隔符号 */
.breadcrumb > .crumb:not(:first-child)::before,
.breadcrumb > .placeholder-crumb::before {
  content: '';
}
.breadcrumb::before {
  content: '';
}
/* 用 gap 和结构自带间隔即可 */

/* ════════ 搜索 ════════ */
.search-bar {
  display: flex;
  align-items: center;
  margin: 20rpx 32rpx 8rpx;
  padding: 14rpx 20rpx;
  background: #f5f6f8;
  border-radius: 12rpx;
  flex-shrink: 0;
}
.search-input {
  flex: 1;
  font-size: 26rpx;
  color: #333;
  margin-left: 12rpx;
}

/* ════════ 列表 ════════ */
.list {
  flex: 1;
  max-height: 45vh;
  padding-bottom: env(safe-area-inset-bottom);
}
.list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 26rpx 32rpx;
  transition: background 0.12s;
}
.list-item:active {
  background: #f8f8f8;
}
.list-item.active {
  background: #f0f7ff;
}
.item-left {
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
}
.item-text {
  font-size: 30rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.list-item.active .item-text {
  color: #007AFF;
  font-weight: 500;
}
.item-right {
  display: flex;
  align-items: center;
  margin-left: 16rpx;
  flex-shrink: 0;
}
.child-badge {
  display: flex;
  align-items: center;
  gap: 4rpx;
}
.child-count {
  font-size: 22rpx;
  color: #ccc;
}

/* 复选框 */
.checkbox {
  width: 38rpx;
  height: 38rpx;
  border: 2rpx solid #ddd;
  border-radius: 8rpx;
  margin-right: 20rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transition: all 0.15s;
}
.checkbox.on {
  background: #007AFF;
  border-color: #007AFF;
}

/* 空状态 */
.empty {
  padding: 100rpx 0;
  text-align: center;
}
.empty-text {
  font-size: 26rpx;
  color: #bbb;
}

/* ════════ 底部已选 ════════ */
.footer {
  border-top: 1rpx solid #f0f0f0;
  padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom));
  flex-shrink: 0;
}
.footer-tags {
  display: flex;
  flex-wrap: wrap;
  gap: 12rpx;
  margin-bottom: 12rpx;
  max-height: 120rpx;
  overflow-y: auto;
}
.footer-tag {
  display: flex;
  align-items: center;
  gap: 8rpx;
  background: #f0f7ff;
  border-radius: 8rpx;
  padding: 8rpx 16rpx;
}
.footer-tag-text {
  font-size: 22rpx;
  color: #007AFF;
  max-width: 180rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.footer-count {
  text-align: right;
}
.count-text {
  font-size: 22rpx;
  color: #999;
}
</style>
5. 支持异步级联加载
vue 复制代码
<!-- components/PopupPicker.vue -->
<template>
  <view>
    <!-- ════════ 触发器 ════════ -->
    <view @click="open">
      <slot :selected="displayLabel" :selectedPath="displayPath">
        <view class="trigger">
          <text class="trigger-label" v-if="label">{{ label }}</text>
          <text class="trigger-value" :class="{ placeholder: !displayLabel }">
            {{ displayLabel || placeholder }}
          </text>
          <uni-icons type="right" size="16" color="#bbb" />
        </view>
      </slot>
    </view>

    <!-- ════════ 弹窗 ════════ -->
    <uni-popup ref="popup" type="bottom" :is-mask-click="maskClose">
      <view class="panel">
        <!-- 头部 -->
        <view class="panel-header">
          <text class="header-btn" @click="close">取消</text>
          <text class="header-title">{{ title }}</text>
          <text
            v-if="multiple"
            class="header-btn confirm"
            :class="{ disabled: !canConfirm }"
            @click="confirm"
          >确定</text>
          <view v-else style="width: 80rpx" />
        </view>

        <!-- 面包屑 -->
        <view class="breadcrumb" v-if="isTree">
          <view
            v-for="(crumb, idx) in breadcrumbs"
            :key="idx"
            class="crumb"
            :class="{
              active: currentLevel === idx,
              loading: crumbLoading && currentLevel === idx
            }"
            @click="switchLevel(idx)"
          >
            <text class="crumb-text">{{ crumb.label }}</text>
          </view>
          <view v-if="showPlaceholderCrumb" class="crumb placeholder-crumb">
            <text class="crumb-text">请选择</text>
          </view>
        </view>

        <!-- 搜索栏 -->
        <view class="search-bar" v-if="searchable">
          <uni-icons type="search" size="16" color="#999" />
          <input
            class="search-input"
            v-model="keyword"
            placeholder="搜索..."
            :adjust-position="false"
          />
          <view v-if="keyword" @click="keyword = ''">
            <uni-icons type="clear" size="16" color="#ccc" />
          </view>
        </view>

        <!-- ════════ 选项列表 ════════ -->
        <scroll-view class="list" scroll-y scroll-with-animation>

          <!-- 加载中 -->
          <view v-if="listState === 'loading'" class="state-box">
            <view class="loading-spinner" />
            <text class="state-text">加载中...</text>
          </view>

          <!-- 加载失败 -->
          <view v-else-if="listState === 'error'" class="state-box">
            <uni-icons type="refresh" size="36" color="#ccc" @click="retryLoad" />
            <text class="state-text error-text">{{ errorMsg || '加载失败,点击重试' }}</text>
            <view class="retry-btn" @click="retryLoad">
              <text class="retry-btn-text">重新加载</text>
            </view>
          </view>

          <!-- 正常列表 -->
          <template v-else>
            <view
              v-for="item in displayList"
              :key="item[valueKey]"
              class="list-item"
              :class="{
                active: isItemActive(item),
                disabled: item._disabled
              }"
              @click="handleTap(item)"
            >
              <view class="item-left">
                <!-- 多选复选框 -->
                <view v-if="showCheckbox(item)" class="checkbox" :class="{ on: isItemActive(item) }">
                  <uni-icons v-if="isItemActive(item)" type="checkmarkempty" size="13" color="#fff" />
                </view>
                <text class="item-text">{{ item[labelKey] }}</text>
                <text v-if="item._tag" class="item-tag">{{ item._tag }}</text>
              </view>

              <view class="item-right">
                <!-- 单选勾选 -->
                <uni-icons
                  v-if="!multiple && !hasChildren(item) && !item[asyncKey] && isItemActive(item)"
                  type="checkmarkempty"
                  size="18"
                  color="#007AFF"
                />
                <!-- 子级信息 -->
                <view v-if="hasChildren(item) || item[asyncKey]" class="child-info">
                  <text v-if="hasChildren(item)" class="child-count">
                    {{ item[childrenKey].length }}项
                  </text>
                  <text v-else-if="item[asyncKey]" class="child-hint">待加载</text>
                  <!-- 正在加载该子级 -->
                  <view v-if="item._loading" class="mini-spinner" />
                  <uni-icons v-else type="right" size="14" color="#ccc" />
                </view>
              </view>
            </view>

            <!-- 空状态 -->
            <view v-if="displayList.length === 0" class="state-box">
              <text class="state-text">{{ emptyText }}</text>
            </view>
          </template>
        </scroll-view>

        <!-- 底部已选摘要 -->
        <view class="footer" v-if="multiple && tempSelected.length > 0">
          <scroll-view scroll-x class="footer-scroll">
            <view class="footer-tags">
              <view v-for="(tag, idx) in selectedDisplayList" :key="idx" class="footer-tag">
                <text class="footer-tag-text">{{ tag }}</text>
                <view @click.stop="removeSelected(idx)">
                  <uni-icons type="clear" size="14" color="#999" />
                </view>
              </view>
            </view>
          </scroll-view>
          <view class="footer-count">
            <text class="count-text">已选 {{ tempSelected.length }}{{ max < Infinity ? '/' + max : '' }}</text>
          </view>
        </view>
      </view>
    </uni-popup>
  </view>
</template>

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

  props: {
    // ── 数据 ──
    options: { type: Array, default: () => [] },
    labelKey: { type: String, default: 'label' },
    valueKey: { type: String, default: 'value' },
    childrenKey: { type: String, default: 'children' },

    // ── 异步加载 ──
    // 函数签名:(node, level) => Promise<Array>
    loadChildren: { type: Function, default: null },
    // 标记节点有子级但尚未加载的字段名
    // 为 true 时表示该节点有子级,需点击后异步加载
    asyncKey: { type: String, default: 'hasChildren' },
    // 是否缓存已加载的子级数据
    cacheAsync: { type: Boolean, default: true },
    // 是否允许同一节点重新加载
    reloadable: { type: Boolean, default: false },

    // ── 显示 ──
    title: { type: String, default: '请选择' },
    label: { type: String, default: '' },
    placeholder: { type: String, default: '请选择' },
    emptyText: { type: String, default: '暂无数据' },
    searchable: { type: Boolean, default: false },
    maskClose: { type: Boolean, default: true },

    // ── 选择模式 ──
    multiple: { type: Boolean, default: false },
    max: { type: Number, default: Infinity },
    selectParent: { type: Boolean, default: false },
    maxLevel: { type: Number, default: -1 },

    // ── v-model ──
    value: { type: [String, Number, Array], default: '' },

    // ── 返回模式 ──
    // 'leaf'   - 叶子节点的 value
    // 'path'   - 路径 value 数组
    // 'object' - 路径对象数组
    resultType: { type: String, default: 'leaf' }
  },

  data() {
    return {
      keyword: '',
      currentLevel: 0,
      pathStack: [],          // [{item, children, label}]
      tempSelected: [],
      tempPath: [],

      // 异步状态
      listState: 'ready',     // 'ready' | 'loading' | 'error'
      errorMsg: '',
      crumbLoading: false,
      loadingNodeKey: null,   // 当前正在加载子级的节点 key
      asyncCache: new Map()   // 已加载子级的缓存
    }
  },

  computed: {
    // 是否树形结构
    isTree() {
      if (this.pathStack.length > 0) return true
      return this.options.some(item =>
        this.hasChildren(item) || !!item[this.asyncKey]
      )
    },

    // 当前层级数据
    currentChildren() {
      if (this.pathStack.length === 0) return this.options
      const last = this.pathStack[this.pathStack.length - 1]
      return last.children || []
    },

    // 过滤后的展示列表
    displayList() {
      let list = this.currentChildren
      if (this.keyword) {
        const kw = this.keyword.toLowerCase()
        list = list.filter(item =>
          String(item[this.labelKey]).toLowerCase().includes(kw)
        )
      }
      return list
    },

    // 面包屑数据
    breadcrumbs() {
      return this.pathStack.map(entry => ({
        label: entry.item[this.labelKey],
        level: entry.level
      }))
    },

    // 是否显示面包屑占位
    showPlaceholderCrumb() {
      if (this.listState === 'loading') return false
      if (this.maxLevel >= 0) return this.currentLevel <= this.maxLevel
      // 当前列表有可展开节点 → 还有下一级
      return this.currentChildren.some(item =>
        this.hasChildren(item) || !!item[this.asyncKey]
      )
    },

    // 是否最后一级(可选择级)
    isLastLevel() {
      if (this.maxLevel >= 0) return this.currentLevel >= this.maxLevel
      return !this.currentChildren.some(item =>
        this.hasChildren(item) || !!item[this.asyncKey]
      )
    },

    // 触发器显示文字
    displayLabel() {
      if (this.multiple) {
        const arr = this.tempSelected.length ? this.tempSelected : (this.value || [])
        return arr.map(v => this.findLabelByValue(v)).filter(Boolean).join('、')
      }
      if (this.resultType === 'path' && Array.isArray(this.value)) {
        return this.value.map(v => this.findLabelByValue(v)).filter(Boolean).join(' / ')
      }
      return this.findLabelByValue(this.value) || ''
    },

    // 完整路径文本
    displayPath() {
      if (this.tempPath.length) {
        return this.tempPath.map(i => i[this.labelKey]).join(' / ')
      }
      if (Array.isArray(this.value)) {
        return this.value.map(v => this.findLabelByValue(v)).join(' / ')
      }
      return this.displayLabel
    },

    // 多选底部展示
    selectedDisplayList() {
      return this.tempSelected.map(v => this.findLabelByValue(v)).filter(Boolean)
    },

    canConfirm() {
      return this.multiple && this.tempSelected.length > 0
    }
  },

  methods: {
    // ══════════════════════════════════
    //  打开 / 关闭
    // ══════════════════════════════════
    open() {
      this.keyword = ''
      this.currentLevel = 0
      this.pathStack = []
      this.listState = 'ready'
      this.errorMsg = ''
      this.loadingNodeKey = null

      if (this.multiple) {
        this.tempSelected = Array.isArray(this.value) ? [...this.value] : []
      } else {
        this.tempPath = this.reconstructPath(this.value)
      }

      this.$refs.popup.open()
    },

    close() {
      this.$refs.popup.close()
    },

    // ══════════════════════════════════
    //  点击选项
    // ══════════════════════════════════
    handleTap(item) {
      if (item._disabled) return

      const hasSyncKids = this.hasChildren(item)
      const hasAsyncKids = !!item[this.asyncKey]
      const isLeafLevel = this.isLastLevel

      // ── 1. 有同步子级 → 直接进入
      if (hasSyncKids && !isLeafLevel) {
        this.enterChildren(item, item[this.childrenKey])
        return
      }

      // ── 2. 有异步子级标记 → 发起加载
      if (hasAsyncKids && !isLeafLevel) {
        // 已有缓存直接使用
        if (this.cacheAsync && this.asyncCache.has(item[this.valueKey])) {
          const cached = this.asyncCache.get(item[this.valueKey])
          this.enterChildren(item, cached)
          return
        }
        this.loadAsyncChildren(item)
        return
      }

      // ── 3. 叶子节点 / 可选父级 → 执行选中
      if (this.multiple) {
        this.toggleMulti(item)
      } else {
        this.selectSingle(item)
      }
    },

    // ══════════════════════════════════
    //  异步加载子级
    // ══════════════════════════════════
    async loadAsyncChildren(item) {
      if (!this.loadChildren) {
        console.warn('[PopupPicker] loadChildren 未定义,无法异步加载')
        return
      }

      const key = item[this.valueKey]

      // 标记该节点 loading(列表项旁的小 spinner)
      this.$set ? this.$set(item, '_loading', true) : (item._loading = true)
      this.loadingNodeKey = key

      try {
        const children = await this.loadChildren(item, this.currentLevel)

        // 做一份不可变副本写入缓存
        const safeChildren = Array.isArray(children) ? children : []

        if (this.cacheAsync) {
          this.asyncCache.set(key, safeChildren)
        }

        // 将子级挂载到原始数据节点上
        this.$set ? this.$set(item, this.childrenKey, safeChildren) : (item[this.childrenKey] = safeChildren)

        // 清除异步标记
        if (item[this.asyncKey]) {
          this.$set ? this.$set(item, this.asyncKey, false) : (item[this.asyncKey] = false)
        }

        // 如果用户在加载期间没有切换到其他节点,自动进入
        if (this.loadingNodeKey === key) {
          this.enterChildren(item, safeChildren)
        }
      } catch (err) {
        console.error('[PopupPicker] 加载子级失败:', err)
        this.errorMsg = (err && err.message) || '加载失败,请重试'
        this.listState = 'error'
      } finally {
        this.$set ? this.$set(item, '_loading', false) : (item._loading = false)
        if (this.loadingNodeKey === key) {
          this.loadingNodeKey = null
        }
      }
    },

    // 重试
    retryLoad() {
      if (this.pathStack.length === 0) {
        this.listState = 'ready'
        return
      }

      const last = this.pathStack[this.pathStack.length - 1]
      const item = last.item

      this.listState = 'ready'
      this.loadAsyncChildren(item)
    },

    // ══════════════════════════════════
    //  进入子级
    // ══════════════════════════════════
    enterChildren(item, children) {
      this.keyword = ''
      this.listState = 'ready'

      this.pathStack.push({
        item,
        children,
        level: this.currentLevel
      })
      this.currentLevel = this.pathStack.length
    },

    // ══════════════════════════════════
    //  单选
    // ══════════════════════════════════
    selectSingle(item) {
      const path = [...this.pathStack.map(e => e.item), item]
      const result = this.buildResult(path, item)

      this.$emit('input', result.value)
      this.$emit('change', result)
      this.close()
    },

    // ══════════════════════════════════
    //  多选
    // ══════════════════════════════════
    toggleMulti(item) {
      const val = item[this.valueKey]
      const idx = this.tempSelected.indexOf(val)
      if (idx > -1) {
        this.tempSelected.splice(idx, 1)
      } else if (this.tempSelected.length < this.max) {
        this.tempSelected.push(val)
      }
    },

    confirm() {
      if (!this.multiple || !this.canConfirm) return

      this.$emit('input', [...this.tempSelected])
      const selected = this.tempSelected.map(v => this.findItemByValue(v)).filter(Boolean)
      this.$emit('change', selected)
      this.close()
    },

    // ══════════════════════════════════
    //  面包屑导航
    // ══════════════════════════════════
    switchLevel(idx) {
      if (idx >= this.pathStack.length) return
      this.keyword = ''
      this.listState = 'ready'
      this.pathStack = this.pathStack.slice(0, idx)
      this.currentLevel = idx
    },

    // ══════════════════════════════════
    //  底部已选移除
    // ══════════════════════════════════
    removeSelected(idx) {
      this.tempSelected.splice(idx, 1)
    },

    // ══════════════════════════════════
    //  构建结果
    // ══════════════════════════════════
    buildResult(path, leaf) {
      switch (this.resultType) {
        case 'path':
          return {
            value: path.map(i => i[this.valueKey]),
            path,
            item: leaf
          }
        case 'object':
          return {
            value: path.map(i => ({
              [this.labelKey]: i[this.labelKey],
              [this.valueKey]: i[this.valueKey]
            })),
            path,
            item: leaf
          }
        default:
          return {
            value: leaf[this.valueKey],
            path,
            item: leaf
          }
      }
    },

    // ══════════════════════════════════
    //  工具方法
    // ══════════════════════════════════
    hasChildren(item) {
      return item[this.childrenKey] && item[this.childrenKey].length > 0
    },

    isItemActive(item) {
      const val = item[this.valueKey]
      if (this.multiple) return this.tempSelected.includes(val)
      if (this.resultType === 'path' && Array.isArray(this.value)) {
        return this.value[this.currentLevel] === val
      }
      return this.value === val
    },

    showCheckbox(item) {
      if (!this.multiple) return false
      return this.isLastLevel || this.selectParent || (!this.hasChildren(item) && !item[this.asyncKey])
    },

    findLabelByValue(value, list) {
      const item = this.findItemByValue(value, list)
      return item ? item[this.labelKey] : ''
    },

    findItemByValue(value, list) {
      list = list || this.options
      for (const item of list) {
        if (item[this.valueKey] === value) return item
        if (item[this.childrenKey]) {
          const found = this.findItemByValue(value, item[this.childrenKey])
          if (found) return found
        }
      }
      return null
    },

    reconstructPath(value) {
      if (!value) return []
      const path = []
      const search = (list) => {
        for (const item of list) {
          path.push(item)
          if (item[this.valueKey] === value) return true
          if (item[this.childrenKey] && search(item[this.childrenKey])) return true
          path.pop()
        }
        return false
      }
      search(this.options)
      return path
    }
  }
}
</script>

<style scoped>
/* ════════ 触发器 ════════ */
.trigger {
  display: flex;
  align-items: center;
  background: #fff;
  border-radius: 16rpx;
  padding: 28rpx 24rpx;
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.trigger-label {
  font-size: 28rpx;
  color: #333;
  margin-right: 24rpx;
  white-space: nowrap;
}
.trigger-value {
  flex: 1;
  text-align: right;
  font-size: 28rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.trigger-value.placeholder {
  color: #bbb;
}

/* ════════ 面板 ════════ */
.panel {
  background: #fff;
  border-radius: 24rpx 24rpx 0 0;
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 28rpx 32rpx 20rpx;
  border-bottom: 1rpx solid #f0f0f0;
  flex-shrink: 0;
}
.header-title {
  font-size: 32rpx;
  font-weight: 600;
  color: #222;
}
.header-btn {
  font-size: 28rpx;
  color: #999;
  min-width: 80rpx;
  text-align: center;
}
.header-btn.confirm {
  color: #007AFF;
  font-weight: 600;
}
.header-btn.confirm.disabled {
  color: #ccc;
}

/* ════════ 面包屑 ════════ */
.breadcrumb {
  display: flex;
  align-items: center;
  padding: 20rpx 32rpx 0;
  flex-shrink: 0;
  flex-wrap: wrap;
  gap: 12rpx;
}
.crumb {
  padding: 10rpx 24rpx;
  border-radius: 24rpx;
  background: #f5f6f8;
  transition: all 0.2s;
  position: relative;
}
.crumb.active {
  background: #007AFF;
}
.crumb.loading::after {
  content: '';
  position: absolute;
  top: 4rpx;
  right: 4rpx;
  width: 10rpx;
  height: 10rpx;
  border-radius: 50%;
  background: #ff9500;
  animation: blink 0.8s infinite;
}
.crumb-text {
  font-size: 24rpx;
  color: #666;
}
.crumb.active .crumb-text {
  color: #fff;
  font-weight: 500;
}
.placeholder-crumb {
  background: transparent;
  border: 1rpx dashed #ddd;
}
.placeholder-crumb .crumb-text {
  color: #bbb;
}
@keyframes blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.3; }
}

/* ════════ 搜索 ════════ */
.search-bar {
  display: flex;
  align-items: center;
  margin: 20rpx 32rpx 8rpx;
  padding: 14rpx 20rpx;
  background: #f5f6f8;
  border-radius: 12rpx;
  flex-shrink: 0;
}
.search-input {
  flex: 1;
  font-size: 26rpx;
  color: #333;
  margin-left: 12rpx;
}

/* ════════ 列表 ════════ */
.list {
  flex: 1;
  max-height: 50vh;
  padding-bottom: env(safe-area-inset-bottom);
}
.list-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 26rpx 32rpx;
  transition: background 0.12s;
}
.list-item:active {
  background: #f8f8f8;
}
.list-item.active {
  background: #f0f7ff;
}
.list-item.disabled {
  opacity: 0.4;
  pointer-events: none;
}
.item-left {
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 0;
}
.item-text {
  font-size: 30rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.list-item.active .item-text {
  color: #007AFF;
  font-weight: 500;
}
.item-tag {
  font-size: 20rpx;
  color: #ff9500;
  background: #fff8e6;
  border-radius: 6rpx;
  padding: 2rpx 10rpx;
  margin-left: 12rpx;
  white-space: nowrap;
}
.item-right {
  display: flex;
  align-items: center;
  margin-left: 16rpx;
  flex-shrink: 0;
}
.child-info {
  display: flex;
  align-items: center;
  gap: 8rpx;
}
.child-count {
  font-size: 22rpx;
  color: #ccc;
}
.child-hint {
  font-size: 22rpx;
  color: #ff9500;
}

/* ════════ 加载状态 ════════ */
.state-box {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 100rpx 0;
  gap: 20rpx;
}
.state-text {
  font-size: 26rpx;
  color: #bbb;
}
.state-text.error-text {
  color: #999;
}

.loading-spinner {
  width: 48rpx;
  height: 48rpx;
  border: 4rpx solid #eee;
  border-top-color: #007AFF;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
.mini-spinner {
  width: 28rpx;
  height: 28rpx;
  border: 3rpx solid #eee;
  border-top-color: #007AFF;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
@keyframes spin {
  to { transform: rotate(360deg); }
}

.retry-btn {
  margin-top: 16rpx;
  padding: 16rpx 48rpx;
  background: #f5f6f8;
  border-radius: 32rpx;
}
.retry-btn-text {
  font-size: 26rpx;
  color: #007AFF;
}

/* ════════ 复选框 ════════ */
.checkbox {
  width: 38rpx;
  height: 38rpx;
  border: 2rpx solid #ddd;
  border-radius: 8rpx;
  margin-right: 20rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transition: all 0.15s;
}
.checkbox.on {
  background: #007AFF;
  border-color: #007AFF;
}

/* ════════ 底部 ════════ */
.footer {
  border-top: 1rpx solid #f0f0f0;
  padding: 16rpx 32rpx calc(16rpx + env(safe-area-inset-bottom));
  flex-shrink: 0;
}
.footer-scroll {
  white-space: nowrap;
}
.footer-tags {
  display: inline-flex;
  flex-wrap: nowrap;
  gap: 12rpx;
  padding-bottom: 12rpx;
}
.footer-tag {
  display: inline-flex;
  align-items: center;
  gap: 8rpx;
  background: #f0f7ff;
  border-radius: 8rpx;
  padding: 8rpx 16rpx;
}
.footer-tag-text {
  font-size: 22rpx;
  color: #007AFF;
  max-width: 180rpx;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.footer-count {
  text-align: right;
}
.count-text {
  font-size: 22rpx;
  color: #999;
}
</style>

如何使用组件:

vue 复制代码
<template>
  <view class="page">

    <!-- ① 级联异步:省 → 市 → 区 -->
    <PopupPicker
      v-model="district"
      :options="provinceList"
      :load-children="loadRegion"
      title="选择地区"
      label="所在地区"
      placeholder="请选择省/市/区"
      result-type="path"
      searchable
      @change="onRegionChange"
    />

    <!-- ② 异步 + 多选(末级打标签) -->
    <PopupPicker
      v-model="categoryIds"
      :options="rootCategories"
      :load-children="loadCategories"
      async-key="isParent"
      title="选择商品分类"
      label="商品分类"
      placeholder="请选择分类"
      multiple
      searchable
      :max="5"
      @change="onCategoryChange"
    />

    <!-- ③ 混合模式:部分同步 + 部分异步 -->
    <PopupPicker
      v-model="deptPath"
      :options="mixedTree"
      :load-children="loadDeptMembers"
      async-key="hasChildren"
      title="选择人员"
      label="负责人"
      placeholder="请选择"
      result-type="path"
      @change="onMemberChange"
    />

    <!-- ④ 普通扁平列表(完全兼容,不传 loadChildren) -->
    <PopupPicker
      v-model="simple"
      :options="simpleList"
      title="选择选项"
      label="普通选择"
      placeholder="请选择"
      @change="onSimpleChange"
    />

  </view>
</template>

<script>
import PopupPicker from '@/components/PopupPicker.vue'

export default {
  components: { PopupPicker },
  data() {
    return {
      district: [],
      categoryIds: [],
      deptPath: [],
      simple: '',

      // ── 省份(首级同步数据)──
      provinceList: [
        { label: '浙江省', value: 'zj', hasChildren: true },
        { label: '江苏省', value: 'js', hasChildren: true },
        { label: '广东省', value: 'gd', hasChildren: true },
        { label: '四川省', value: 'sc', hasChildren: true }
      ],

      // ── 商品根分类 ──
      rootCategories: [
        { label: '电子产品', value: 'elec', isParent: true },
        { label: '服装鞋帽', value: 'cloth', isParent: true },
        { label: '食品饮料', value: 'food', isParent: true },
        { label: '图书音像', value: 'book', isParent: true }
      ],

      // ── 混合树(部分节点同步有子级,部分需要异步)──
      mixedTree: [
        {
          label: '技术中心', value: 'tech',
          children: [
            { label: '前端组', value: 'fe', hasChildren: true },
            { label: '后端组', value: 'be', hasChildren: true }
          ]
        },
        {
          label: '产品部', value: 'pm',
          children: [
            { label: '策略组', value: 'strategy', hasChildren: true },
            { label: '体验组', value: 'ux', hasChildren: true }
          ]
        }
      ],

      // ── 普通列表 ──
      simpleList: [
        { label: '选项 A', value: 'a' },
        { label: '选项 B', value: 'b' },
        { label: '选项 C', value: 'c' }
      ]
    }
  },

  methods: {
    // ═══════════════════════════════════
    //  ① 地区异步加载
    // ═══════════════════════════════════
    async loadRegion(node, level) {
      // level 0 = 省→市, level 1 = 市→区
      console.log(`加载地区: ${node.label} (level ${level})`)

      // 模拟 API 请求
      const res = await this.mockApi('/api/region', {
        parentCode: node.value,
        level: level + 1
      })
      return res.data
    },

    // ═══════════════════════════════════
    //  ② 商品分类异步加载
    // ═══════════════════════════════════
    async loadCategories(node, level) {
      console.log(`加载分类: ${node.label}`)

      const res = await this.mockApi('/api/categories', {
        parentId: node.value
      })
      return res.data
    },

    // ═══════════════════════════════════
    //  ③ 部门成员异步加载
    // ═══════════════════════════════════
    async loadDeptMembers(node, level) {
      // level=1 时加载组内成员
      const res = await this.mockApi('/api/dept/members', {
        deptId: node.value
      })
      return res.data
    },

    // ═══════════════════════════════════
    //  模拟 API(实际替换成真实请求)
    // ═══════════════════════════════════
    mockApi(url, params) {
      return new Promise((resolve, reject) => {
        setTimeout(() => {
          // 模拟错误(10% 概率)
          if (Math.random() < 0.1) {
            reject(new Error('网络请求超时'))
            return
          }

          const mockData = {
            '/api/region': {
              zj: [
                { label: '杭州市', value: 'hz', hasChildren: true },
                { label: '宁波市', value: 'nb', hasChildren: true },
                { label: '温州市', value: 'wz', hasChildren: true }
              ],
              js: [
                { label: '南京市', value: 'nj', hasChildren: true },
                { label: '苏州市', value: 'sz', hasChildren: true }
              ],
              gd: [
                { label: '广州市', value: 'gz', hasChildren: true },
                { label: '深圳市', value: 'sz_city', hasChildren: true }
              ],
              sc: [
                { label: '成都市', value: 'cd', hasChildren: true },
                { label: '绵阳市', value: 'my' }
              ],
              hz: [
                { label: '西湖区', value: 'xihu' },
                { label: '余杭区', value: 'yuhang' },
                { label: '滨江区', value: 'binjiang' },
                { label: '萧山区', value: 'xiaoshan' },
                { label: '拱墅区', value: 'gongshu' }
              ],
              nb: [
                { label: '鄞州区', value: 'yinzhou' },
                { label: '海曙区', value: 'haishu' },
                { label: '北仑区', value: 'beilun' }
              ],
              wz: [
                { label: '鹿城区', value: 'lucheng' },
                { label: '龙湾区', value: 'longwan' }
              ],
              nj: [
                { label: '玄武区', value: 'xuanwu' },
                { label: '鼓楼区', value: 'gulou' },
                { label: '建邺区', value: 'jianye' }
              ],
              sz: [
                { label: '姑苏区', value: 'gusu' },
                { label: '虎丘区', value: 'huqiu' }
              ],
              gz: [
                { label: '天河区', value: 'tianhe' },
                { label: '越秀区', value: 'yuexiu' }
              ],
              sz_city: [
                { label: '南山区', value: 'nanshan' },
                { label: '福田区', value: 'futian' },
                { label: '宝安区', value: 'baoan' }
              ],
              cd: [
                { label: '武侯区', value: 'wuhou' },
                { label: '锦江区', value: 'jinjiang' },
                { label: '青羊区', value: 'qingyang' }
              ]
            },

            '/api/categories': {
              elec: [
                { label: '手机通讯', value: 'phone', isParent: true },
                { label: '电脑办公', value: 'computer', isParent: true },
                { label: '智能设备', value: 'smart', isParent: true }
              ],
              cloth: [
                { label: '男装', value: 'men' },
                { label: '女装', value: 'women' },
                { label: '运动鞋', value: 'shoes' }
              ],
              food: [
                { label: '零食', value: 'snack' },
                { label: '饮料', value: 'drink' },
                { label: '生鲜', value: 'fresh' }
              ],
              book: [
                { label: '计算机', value: 'cs_book' },
                { label: '文学', value: 'literature' },
                { label: '教育', value: 'education' }
              ],
              phone: [
                { label: '小米', value: 'xiaomi_phone', _tag: '热门' },
                { label: '华为', value: 'huawei_phone', _tag: '热门' },
                { label: '苹果', value: 'apple_phone' },
                { label: 'OPPO', value: 'oppo_phone' },
                { label: 'vivo', value: 'vivo_phone' }
              ],
              computer: [
                { label: '笔记本', value: 'laptop' },
                { label: '台式机', value: 'desktop' },
                { label: '平板电脑', value: 'tablet' }
              ],
              smart: [
                { label: '智能手表', value: 'watch' },
                { label: '智能音箱', value: 'speaker' },
                { label: '智能门锁', value: 'lock' }
              ]
            },

            '/api/dept/members': {
              fe: [
                { label: '张三', value: 'zhangsan' },
                { label: '李四', value: 'lisi' },
                { label: '王五', value: 'wangwu' }
              ],
              be: [
                { label: '赵六', value: 'zhaoliu' },
                { label: '钱七', value: 'qianqi' }
              ],
              strategy: [
                { label: '孙八', value: 'sunba' }
              ],
              ux: [
                { label: '周九', value: 'zhoujiu' },
                { label: '吴十', value: 'wushi' }
              ]
            }
          }

          const data = mockData[url]
          const result = data ? data[params.parentCode] || [] : []
          resolve({ data: result })
        }, 400 + Math.random() * 600)
      })
    },

    // ═══════════════════════════════════
    //  事件回调
    // ═══════════════════════════════════
    onRegionChange(e) {
      // result-type="path" → e = { values: ['zj','hz','xihu'], path: [...], item: {...} }
      console.log('地区:', e.values, '→', e.path.map(i => i.label).join(' / '))
    },

    onCategoryChange(items) {
      // multiple → items = [{label,value}, ...]
      console.log('分类:', items.map(i => i.label))
    },

    onMemberChange(e) {
      console.log('人员:', e)
    },

    onSimpleChange(item) {
      console.log('选择:', item)
    }
  }
}
</script>

<style scoped>
.page {
  min-height: 100vh;
  background: #f5f5f5;
  padding: 40rpx 32rpx;
  display: flex;
  flex-direction: column;
  gap: 24rpx;
}
</style>

设计说明:

在数据节点上用一个布尔字段(默认 hasChildren,可通过 asyncKey 自定义)标记"有子级但未加载":

js 复制代码
// 前端传入的初始数据
[
  { label: '浙江省', value: 'zj', hasChildren: true },   // 需要异步加载子级
  { label: '杭州市', value: 'hz', children: [...] }      // 子级已同步存在
]

组件根据数据标识自行判断

textile 复制代码
点击节点
  ├── 有 children(同步)→ 直接进入下一级
  ├── 有 asyncKey 标记   → 调用 loadChildren → 成功后进入
  │                     → 失败时显示错误 + 重试
  ├── 有缓存             → 命中缓存直接进入(不重复请求)
  └── 都没有             → 视为叶子节点 → 执行选中

流程图如下:

textile 复制代码
handleTap(item)
     │
     ├─ hasSyncChildren? ──→ enterChildren(item, item.children)
     │
     ├─ hasAsyncFlag?
     │     ├─ cache命中? ──→ enterChildren(item, cache)
     │     └─ 无缓存:
     │           │
     │           ▼
     │     loadAsyncChildren(item)
     │           │
     │           ├── item._loading = true  (列表项旁小spinner)
     │           ├── loadingNodeKey = key   (防止重复点击)
     │           │
     │           ▼
     │     await loadChildren(item, level)
     │           │
     │           ├── 成功:
     │           │     ├── cache.set(key, children)
     │           │     ├── item[childrenKey] = children
     │           │     ├── item[asyncKey] = false
     │           │     └── enterChildren(item, children)
     │           │
     │           └── 失败:
     │                 ├── listState = 'error'
     │                 ├── 显示错误信息 + 重试按钮
     │                 └── retryLoad() → 重新调用
     │
     └─ 都没有 → selectSingle / toggleMulti

组件属性说明:

属性 类型 默认值 说明
value / v-model `String Number Array`
options Array [] 数据源,支持扁平或树结构
labelKey String 'label' 显示文本对应的字段名
valueKey String 'value' 唯一标识对应的字段名
childrenKey String 'children' 子级数组对应的字段名
异步相关
loadChildren Function null 异步加载函数 (node, level) => Promise<Array>
asyncKey String 'hasChildren' 标记"有子级但未加载"的字段名
cacheAsync Boolean true 缓存已加载数据,不重复请求
显示
title String '请选择' 弹窗标题
label String '' 触发器左侧标签文字
placeholder String '请选择' 未选中时的占位文字
emptyText String '暂无数据' 列表为空时的提示
searchable Boolean false 是否显示搜索栏
maskClose Boolean true 点击遮罩是否关闭弹窗
选择模式
multiple Boolean false 是否多选
max Number Infinity 多选最大数量
selectParent Boolean false 是否允许选择有子级的父节点
maxLevel Number -1 限制最大层级(0起),-1 不限制
resultType String 'leaf' 返回值模式,见下方说明

resultType 详解

v-model 类型 change 回传 适用场景
'leaf' `String Number` item(叶子节点对象)
'path' Array { value, path, item } 需要完整路径(如省市区三级码)
'object' Array<Object> { path, item } 需要路径上每级的 label + value

异步加载时对接服务端

js 复制代码
// 真实 API 对接
async loadRegion(node, level) {
  const { data } = await uni.request({
    url: 'https://api.example.com/region/list',
    method: 'GET',
    data: {
      parentCode: node.value,
      level: level + 1
    }
  })

  // 按接口返回格式映射
  return data.list.map(item => ({
    label: item.name,
    value: item.code,
    hasChildren: item.childCount > 0   // 标记是否有下级
  }))
}

// 对接 Element Plus / Ant Design 等后端的 tree 接口
async loadTree(node, level) {
  const { data } = await api.get('/tree/children', {
    params: { id: node.value }
  })

  return data.map(item => ({
    label: item.title,
    value: item.id,
    hasChildren: item.isLeaf === 0
  }))
}
相关推荐
拆房老料1 小时前
BaseMetas FileView 1.2.0 发布:Office/WPS 大文件预览与内存安全优化实测
前端·产品运营·开源软件
blns_yxl1 小时前
Promise封装Fetch + 重试机制(HTML+JS)
前端·javascript·html
Elastic 中国社区官方博客1 小时前
使用重新设计的 AutoOps 更快地进行 Elasticsearch 问题排查
大数据·运维·前端·人工智能·elasticsearch·搜索引擎·全文检索
无巧不成书02181 小时前
Vue+Vue-CLI全平台零基础搭建教程
前端·javascript·vue.js·vue·前端开发·vue-cli·前端环境搭建
何时梦醒1 小时前
⚛️ React 19 组件化实战 —— 从零搭建 Todo List 并吃透组件通信
前端·人工智能·react.js
bonechips1 小时前
React 组件通信:一套 TodoList 讲清 props 与回调
前端·react.js
渣波1 小时前
别把 TodoList 写成面条代码!一文吃透 React 组件通信与状态驱动的核心内功
前端
2601_964702891 小时前
Claude Opus 5 API 开发实战:对话、文本生成与结构化输出
java·服务器·前端