Uniapp 微信小程序:自封装地址级联选择器|支持省市区三级以及省市区街道四级级联、数据懒加载、回显检索、名称 / 编码双输出,输出类型与检索需求一致|港澳台暂不支持选取(个人学习记录)
组件代码template view classpicker-input tapopenPopup text classinput-placeholder{{ regionText || placeholder }}/text up-icon namearrow-right size16 color#999/up-icon /view up-popup :showshow modebottom :round20 closeOnClickOverlay overlay closehandlePopupClose view classpopup-content view classpopup-title{{ popupTitle }}/view view classpopup-header view classbreadcrumb-wrapper scroll-view classbreadcrumb scroll-x show-scrollbarfalse view classbreadcrumb-inner !-- 省份面包屑 -- view classbreadcrumb-item :class{ is-active: currentLevel 0, is-disabled: false } tapswitchLevel(0) {{ tempProvince?.name || 省 }} up-icon namearrow-right size14 :colorcurrentLevel 0 ? #fff:#999/up-icon /view !-- 城市面包屑 -- view classbreadcrumb-item :class{ is-active: currentLevel 1, is-disabled: !tempProvince || Object.keys(tempProvince).length 0 } tapswitchLevel(1) {{ tempCity?.name || 市 }} up-icon namearrow-right size14 :colorcurrentLevel 1 ? #fff:#999/up-icon /view !-- 区县面包屑 -- view classbreadcrumb-item :class{ is-active: currentLevel 2, is-disabled: !tempCity || Object.keys(tempCity).length 0 } tapswitchLevel(2) {{ tempDistrict?.name || 区 }} up-icon v-iflevelNum 4 namearrow-right size14 :colorcurrentLevel 2 ? #fff:#999/up-icon /view !-- 街道面包屑 -- view v-iflevelNum 4 classbreadcrumb-item :class{ is-active: currentLevel 3, is-disabled: !tempDistrict || Object.keys(tempDistrict).length 0 } tapswitchLevel(3) {{ tempStreet?.name || 街道 }} /view /view /scroll-view /view /view view classregion-body !-- 省份选择列表 -- scroll-view v-ifcurrentLevel 0 classselection-view scroll-y enable-flex view classselection-list view v-ifloadError currentLevel 0 classerror-message加载失败 view classretry-btn tapretryLoad重试/view /view view v-ifprovinceList.length 0 !isLoading !loadError classempty-message暂无省份数据 /view view v-for(item, index) in provinceList :keyprovince-${index}-${item.name} classselection-item :class{ is-selected: tempProvince?.name item.name } tapselectProvince(item) text classitem-text{{ item.name }}/text up-icon v-iftempProvince?.name item.name namecheckmark size20 color#1989fa/up-icon /view /view /scroll-view !-- 城市选择列表 -- scroll-view v-else-ifcurrentLevel 1 classselection-view scroll-y enable-flex view classselection-list view v-ifloadError currentLevel 1 classerror-message加载失败 view classretry-btn tapretryLoad重试/view /view view v-ifcityList.length 0 !isLoading !loadError classempty-message暂无城市数据 /view view v-for(item, index) in cityList :keycity-${index}-${item.name} classselection-item :class{ is-selected: tempCity?.name item.name } tapselectCity(item) text classitem-text{{ item.name }}/text up-icon v-iftempCity?.name item.name namecheckmark size20 color#1989fa/up-icon /view /view /scroll-view !-- 区县选择列表 -- scroll-view v-else-ifcurrentLevel 2 classselection-view scroll-y enable-flex view classselection-list view v-ifloadError currentLevel 2 classerror-message加载失败 view classretry-btn tapretryLoad重试/view /view view v-ifdistrictList.length 0 !isLoading !loadError classempty-message暂无区县数据 /view view v-for(item, index) in districtList :keydistrict-${index}-${item.name} classselection-item :class{ is-selected: tempDistrict?.name item.name } tapselectDistrict(item) text classitem-text{{ item.name }}/text up-icon v-iftempDistrict?.name item.name namecheckmark size20 color#1989fa/up-icon /view /view /scroll-view !-- 街道选择列表 -- scroll-view v-else-ifcurrentLevel 3 levelNum 4 classselection-view scroll-y enable-flex view classselection-list view v-ifloadError currentLevel 3 classerror-message加载失败 view classretry-btn tapretryLoad重试/view /view view v-ifstreetList.length 0 !isLoading !loadError classempty-message 该区县下暂无街道数据/view view v-for(item, index) in streetList :keystreet-${index}-${item.name} classselection-item :class{ is-selected: tempStreet?.name item.name } tapselectStreet(item) text classitem-text{{ item.name }}/text up-icon v-iftempStreet?.name item.name namecheckmark size20 color#1989fa/up-icon /view /view /scroll-view /view !-- 操作按钮栏 -- view classaction-bar view classaction-btn secondary tapresetSelection重置/view view classaction-btn primary :class{ is-disabled: !canConfirm } tapconfirmRegion确定/view /view !-- 加载中状态 -- view v-ifisLoading classloading-container view classlight-loading view classlight-spinner/view /view /view /view /up-popup /template script setup import { ref, computed, watch, onUnmounted, nextTick } from vue import { getRegionData } from /http/api.js // 组件入参 const props defineProps({ modelValue: Array, returnType: { type: String, default: name, validator: (val) [code, name].includes(val) }, level: { type: [Number, String], default: 3, validator: (val) [3, 4].includes(Number(val)) }, popupTitle: { type: String, default: 选择地区 }, placeholder: { type: String, default: 请选择地区 } }) const emit defineEmits([update:modelValue]) // 计算属性 const levelNum computed(() Number(props.level)) const maxLevel computed(() levelNum.value - 1) // 状态管理 const show ref(false) const currentLevel ref(0) const loadError ref(false) const isLoading ref(false) let timeoutTimer null // 地区数据缓存 const provinceList ref([]) const cityList ref([]) const districtList ref([]) const streetList ref([]) // 临时选择值 const tempProvince ref(null) const tempCity ref(null) const tempDistrict ref(null) const tempStreet ref(null) // 最终确认值 const finalProvince ref(null) const finalCity ref(null) const finalDistrict ref(null) const finalStreet ref(null) // 辅助缓存记录无街道数据的区县 const noStreetRecord ref({}) // 接口限流配置 const requestQueue ref([]) const isRequesting ref(false) let lastRequestTime 0 const REQUEST_INTERVAL 350 // 接口请求限流处理 async function requestWithLimit(requestFn) { return new Promise((resolve, reject) { requestQueue.value.push({ requestFn, resolve, reject }) processRequestQueue() }) } // 处理请求队列 async function processRequestQueue() { if (isRequesting.value || requestQueue.value.length 0) return isRequesting.value true const { requestFn, resolve, reject } requestQueue.value.shift() try { const now Date.now() const timeSinceLastRequest now - lastRequestTime const waitTime timeSinceLastRequest REQUEST_INTERVAL ? REQUEST_INTERVAL - timeSinceLastRequest : 0 if (waitTime 0) { await new Promise(resolve setTimeout(resolve, waitTime)) } lastRequestTime Date.now() const result await requestFn() resolve(result) } catch (error) { reject(error) } finally { isRequesting.value false setTimeout(() processRequestQueue(), 0) } } // 确认按钮禁用状态 const canConfirm computed(() { const baseValid !!tempProvince.value?.name !!tempCity.value?.name !!tempDistrict.value?.name return levelNum.value 3 ? baseValid : baseValid !!tempStreet.value?.name }) // 输入框显示文本 const regionText computed(() { const parts [finalProvince.value?.name, finalCity.value?.name, finalDistrict.value?.name] if (levelNum.value 4 finalStreet.value?.name) parts.push(finalStreet.value?.name) return parts.filter(Boolean).join(/) }) // 计算最后有效层级 function getLastValidLevel() { let lastLevel 0 if (finalProvince.value) lastLevel 1 if (finalCity.value) lastLevel 2 if (finalDistrict.value levelNum.value 4) { lastLevel (finalStreet.value || noStreetRecord.value[finalDistrict.value.adcode]) ? 3 : 2 } return Math.min(lastLevel, maxLevel.value) } // 预加载层级数据 async function preloadLevelData(targetLevel) { try { if (targetLevel 1 tempProvince.value cityList.value.length 0) await loadCityData() if (targetLevel 2 tempCity.value districtList.value.length 0) await loadDistrictData() if (targetLevel 3 tempDistrict.value streetList.value.length 0) await loadStreetData() } catch (error) { console.warn(预加载层级数据失败:, error) currentLevel.value getLastValidLevel() } } // 显示加载状态 function showLoading() { isLoading.value true loadError.value false clearTimeout(timeoutTimer) timeoutTimer setTimeout(() { hideLoading() loadError.value true }, 5000) } // 隐藏加载状态 function hideLoading() { isLoading.value false clearTimeout(timeoutTimer) } // 切换选择层级 async function switchLevel(level) { if (level 1 !tempProvince?.value) return if (level 2 !tempCity?.value) return if (level 3 !tempDistrict?.value) return if (level maxLevel.value) return currentLevel.value level try { switch (level) { case 0: await loadProvince(); break case 1: if (tempProvince.value !cityList.value.length) await loadCityData(); break case 2: if (tempCity.value !districtList.value.length) await loadDistrictData(); break case 3: if (levelNum.value 4 tempDistrict.value) await loadStreetData(); break } } catch (error) { loadError.value true } } // 加载省份数据 async function loadProvince() { if (provinceList.value.length) return showLoading() try { const res await requestWithLimit(() getRegionData({ keywords: 中国, subdistrict: 1 })) const list res?.data?.districts?.[0]?.districts || [] const filtered list.filter(item ![台湾省, 香港特别行政区, 澳门特别行政区].includes(item.name)) provinceList.value filtered.map(i ({ name: i.name, adcode: i.adcode })) hideLoading() } catch (error) { hideLoading() loadError.value true } } // 加载城市数据 async function loadCityData() { showLoading() try { const adcode tempProvince.value.adcode const res await requestWithLimit(() getRegionData({ keywords: adcode, subdistrict: 1 })) cityList.value (res?.data?.districts?.[0]?.districts || []).map(i ({ name: i.name, adcode: i.adcode })) hideLoading() } catch (error) { hideLoading() loadError.value true } } // 加载区县数据 async function loadDistrictData() { showLoading() try { const adcode tempCity.value.adcode const res await requestWithLimit(() getRegionData({ keywords: adcode, subdistrict: 1 })) const districts res?.data?.districts?.[0]?.districts || [] districtList.value districts.map(i ({ name: i.name, adcode: i.adcode })) hideLoading() } catch (error) { hideLoading() loadError.value true } } // 加载街道数据带重试机制 async function loadStreetData(retryCount 0) { if (!tempDistrict.value) return Promise.reject(new Error(缺少区县信息)) const districtAdcode tempDistrict.value.adcode ; if (retryCount 3) { hideLoading() loadError.value true return Promise.reject(new Error(加载重试次数用尽)) } showLoading() try { const res await requestWithLimit(() getRegionData({ keywords: districtAdcode, subdistrict: 3 })) const streets res?.data?.districts?.[0]?.districts || [] streetList.value streets.map(i ({ name: i.name, adcode: i.adcode })) if (streetList.value.length 0) { noStreetRecord.value[districtAdcode] true; } hideLoading() return Promise.resolve(streetList.value) } catch (error) { hideLoading() await new Promise(resolve setTimeout(resolve, 1000 * (retryCount 1))) return loadStreetData(retryCount 1) } } // 打开选择弹窗 async function openPopup() { resetTempSelection() await nextTick() show.value true await nextTick() // 回显数据赋值 if (finalProvince.value) tempProvince.value { ...finalProvince.value } if (finalCity.value) tempCity.value { ...finalCity.value } if (finalDistrict.value) tempDistrict.value { ...finalDistrict.value } if (finalStreet.value) tempStreet.value { ...finalStreet.value } await loadProvince() const targetLevel getLastValidLevel() currentLevel.value targetLevel await preloadLevelData(targetLevel) } // 弹窗关闭处理 function handlePopupClose() { show.value false } // 重置临时选择状态 function resetTempSelection() { tempProvince.value null tempCity.value null tempDistrict.value null tempStreet.value null currentLevel.value 0 loadError.value false } // 选择省份 async function selectProvince(item) { tempProvince.value { ...item } tempCity.value null tempDistrict.value null tempStreet.value null cityList.value [] districtList.value [] streetList.value [] noStreetRecord.value {} await loadCityData() currentLevel.value 1 } // 选择城市 async function selectCity(item) { tempCity.value { ...item } tempDistrict.value null tempStreet.value null districtList.value [] streetList.value [] noStreetRecord.value {} await loadDistrictData() currentLevel.value 2 } // 选择区县 async function selectDistrict(item) { tempDistrict.value { ...item } tempStreet.value null streetList.value [] if (levelNum.value 3) return if (levelNum.value 4) { try { const streets await loadStreetData() if (streets.length 0) { currentLevel.value 3 } else { uni.showToast({ title: 该区县暂无街道数据可直接确认, icon: none }) } } catch (error) { uni.showToast({ title: 街道数据加载失败可直接确认, icon: none }) } } } // 选择街道 function selectStreet(item) { tempStreet.value { ...item } } // 重置选择 function resetSelection() { resetTempSelection() finalProvince.value null finalCity.value null finalDistrict.value null finalStreet.value null cityList.value [] districtList.value [] streetList.value [] noStreetRecord.value {} requestQueue.value [] isRequesting.value false emit(update:modelValue, []) } // 确认选择 function confirmRegion() { if (!canConfirm.value) return finalProvince.value { ...tempProvince.value } finalCity.value { ...tempCity.value } finalDistrict.value { ...tempDistrict.value } finalStreet.value { ...tempStreet.value } const result props.returnType code ? [ finalProvince.value.adcode, finalCity.value.adcode, finalDistrict.value.adcode, ...(levelNum.value 4 ? [finalStreet.value?.adcode || ] : []) ] : [ finalProvince.value.name, finalCity.value.name, finalDistrict.value.name, ...(levelNum.value 4 ? [finalStreet.value?.name || ] : []) ] emit(update:modelValue, result) show.value false } // 重新加载数据 async function retryLoad() { loadError.value false if (currentLevel.value 0) provinceList.value [] if (currentLevel.value 1) cityList.value [] if (currentLevel.value 2) districtList.value [] if (currentLevel.value 3) streetList.value [] switch (currentLevel.value) { case 0: await loadProvince(); break case 1: await loadCityData(); break case 2: await loadDistrictData(); break case 3: await loadStreetData(); break } } // 监听绑定值变化实现回显 watch(() props.modelValue, async (val) { if (!val?.length) { finalProvince.value null finalCity.value null finalDistrict.value null finalStreet.value null tempProvince.value null tempCity.value null tempDistrict.value null tempStreet.value null currentLevel.value 0 return } const requiredLen levelNum.value 3 ? 3 : 4 if (val.length requiredLen) return const [p, c, d, s] val await loadProvince() finalProvince.value props.returnType code ? provinceList.value.find(i i.adcode p ) : provinceList.value.find(i i.name p) if (!finalProvince.value) return tempProvince.value { ...finalProvince.value } await loadCityData() finalCity.value props.returnType code ? cityList.value.find(i i.adcode c ) : cityList.value.find(i i.name c) if (!finalCity.value) return tempCity.value { ...finalCity.value } await loadDistrictData() finalDistrict.value props.returnType code ? districtList.value.find(i i.adcode d ) : districtList.value.find(i i.name d) if (!finalDistrict.value) return tempDistrict.value { ...finalDistrict.value } if (levelNum.value 4) { try { await Promise.race([ loadStreetData(), new Promise((_, reject) setTimeout(() reject(new Error(加载超时)), 8000)) ]) } catch (error) { uni.showToast({ title: 街道数据加载失败已回显至区县, icon: none }) currentLevel.value 2 return } finalStreet.value props.returnType code ? streetList.value.find(i i.adcode s ) : streetList.value.find(i i.name s) tempStreet.value { ...finalStreet.value } if (!finalStreet.value) { uni.showToast({ title: 街道信息不存在已回显至区县, icon: none }) if (s) { finalStreet.value { name: s, adcode: s } tempStreet.value { ...finalStreet.value } } currentLevel.value 2 } else { currentLevel.value 3 } } else { currentLevel.value 2 } currentLevel.value getLastValidLevel() }, { immediate: true, deep: true }) // 组件卸载清理 onUnmounted(() { clearTimeout(timeoutTimer) requestQueue.value [] isRequesting.value false }) /script style scoped langscss .picker-input { height: 100rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 30rpx; background: #f6f6f6; border-radius: 16rpx; border: 2rpx solid #e5e5e5; transition: all 0.2s ease; :active { background: #f0f0f0; border-color: #1989fa; } .input-placeholder { font-size: 30rpx; color: #666; font-weight: 500; } } .popup-content { background: #fff; border-top-left-radius: 24rpx; border-top-right-radius: 24rpx; height: 80vh; display: flex; flex-direction: column; padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); position: relative; overflow: hidden; } .popup-title { font-size: 36rpx; font-weight: 700; color: #222; text-align: center; letter-spacing: 2rpx; padding: 20rpx 0; border-bottom: 1px solid #eee; background: #fafafa; flex-shrink: 0; } .popup-header { padding: 0 20rpx; height: 88rpx; display: flex; align-items: center; border-bottom: 1px solid #eee; background: #fafafa; flex-shrink: 0; box-sizing: border-box; .breadcrumb-wrapper { flex: 1; height: 60rpx; min-width: 400rpx; position: relative; overflow: hidden; .breadcrumb { width: 100%; height: 100%; white-space: nowrap; .breadcrumb-inner { height: 100%; display: flex; align-items: center; padding: 0 4rpx; transition: all 0.2s ease-in-out; } ::-webkit-scrollbar { display: none; } } } .breadcrumb-item { display: inline-flex; align-items: center; padding: 8rpx 16rpx; font-size: 28rpx; color: #666; white-space: nowrap; border-radius: 12rpx; margin-right: 8rpx; cursor: pointer; height: 44rpx; box-sizing: border-box; transition: all 0.2s ease; .is-active { background: #1989fa; color: #fff; font-weight: 600; .u-icon { color: #fff !important; } } .is-disabled { color: #ccc; cursor: not-allowed; background: #f5f5f5; pointer-events: none; .u-icon { color: #ccc !important; } :active { background: #f5f5f5 !important; transform: none !important; } } :active:not(.is-active):not(.is-disabled) { background: #f0f0f0; } .u-icon { margin-left: 8rpx; opacity: 0.7; flex-shrink: 0; } } } .region-body { flex: 1; position: relative; display: flex; flex-direction: column; overflow: hidden; } .selection-view { flex: 1; height: 0; overflow-y: auto; transition: opacity 0.2s ease-in-out; } .selection-list { padding: 10rpx 20rpx; .empty-message { text-align: center; padding: 40rpx 0; color: #999; font-size: 28rpx; } .error-message { text-align: center; padding: 40rpx 0; color: #fa3534; font-size: 28rpx; .retry-btn { display: inline-block; margin-left: 10rpx; padding: 8rpx 16rpx; background: #1989fa; color: #fff; border-radius: 8rpx; font-size: 24rpx; } } } .selection-item { height: 96rpx; display: flex; align-items: center; justify-content: space-between; padding: 0 24rpx; background: #f7f7f7; border-radius: 16rpx; margin-bottom: 16rpx; transition: all 0.2s ease; border: 2rpx solid transparent; .is-selected { background: #eaf4ff; border-color: #1989fa; color: #1989fa; } :active:not(.is-selected) { background: #e6e6e6; transform: scale(0.98); } .item-text { font-size: 32rpx; font-weight: 500; color: #333; flex: 1; } } .loading-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; z-index: 10; display: flex; align-items: center; justify-content: center; transition: opacity 0.2s ease; opacity: 1; } .light-loading { width: 40rpx; height: 40rpx; display: flex; align-items: center; justify-content: center; } .light-spinner { width: 32rpx; height: 32rpx; border-radius: 50%; border: 3rpx solid rgba(25, 137, 250, 0.2); border-top-color: #1989fa; animation: spin 0.8s linear infinite; } keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .action-bar { display: flex; padding: 20rpx; border-top: 1px solid #eee; gap: 16rpx; flex-shrink: 0; .action-btn { flex: 1; height: 88rpx; border-radius: 16rpx; font-size: 32rpx; font-weight: 600; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; .secondary { background: #f5f5f5; color: #666; :active { background: #e8e8e8; } } .primary { background: #1989fa; color: #fff; box-shadow: 0 4rpx 12rpx rgba(25, 137, 250, 0.3); .is-disabled { background: #ccc; box-shadow: none; cursor: not-allowed; } :active:not(.is-disabled) { background: #0e75d8; transform: translateY(2rpx); box-shadow: 0 2rpx 6rpx rgba(25, 137, 250, 0.4); } } } } ::v-deep { .u-popup { z-index: 9999; } .u-popup__content { padding: 0 !important; } .breadcrumb scroll-view { flex: none !important; } } /style引入组件import RegionPicker from /components/RegionPicker.vue;调用组件RegionPicker v-modelvalue /依赖组件Uview-Plushttps://www.cnblogs.com/zhangyouwu/p/18561086行政地理数据来源https://lbs.amap.com/api/webservice/guide/api/district示例图: