Explorar el Código

refactor(frontend): 地址选择统一到 address-picker 组件

- 组件新增 levels(3/4级)、show-trigger、initial 回显、ref.open() 能力
- 修复编辑回显误用 children 接口(改用 /api/streets/{id}/path)、加载态不生效
- 收件人地址、个人资料、服务角色申请复用同一组件,删除内联三级弹窗与 /api/region/* 调用
- 服务角色申请角色卡改为严格三列(每行 flex:1 等分,不依赖 calc/flex gap)
liaoxg hace 5 días
padre
commit
d696d926a9

+ 111 - 33
cfc-frontend/components/address-picker.vue

@@ -1,7 +1,7 @@
 <template>
   <view class="address-picker">
-    <!-- 显示当前选中的地址 -->
-    <view class="current-address" @click="openPicker">
+    <!-- 显示当前选中的地址(show-trigger=false 时由页面自己绘制入口行) -->
+    <view v-if="showTrigger" class="current-address" @click="open">
       <text class="address-text">{{ currentAddress || '请选择地址' }}</text>
       <text class="address-arrow">›</text>
     </view>
@@ -12,7 +12,7 @@
         <!-- 面板头部 -->
         <view class="picker-header">
           <text class="picker-btn" @click="closePicker">取消</text>
-          <text class="picker-title">选择地址</text>
+          <text class="picker-title">选择地区</text>
           <text class="picker-btn picker-confirm" @click="confirmAddress">确定</text>
         </view>
 
@@ -32,11 +32,13 @@
             :class="['crumb', currentLevel === 2 ? 'crumb-active' : '']"
             @click="jumpToLevel(2)"
           >{{ tempDistrict || '区县' }}</text>
-          <text v-if="tempDistrict" class="crumb-arrow">›</text>
-          <text
-            :class="['crumb', currentLevel === 3 ? 'crumb-active' : '']"
-            @click="jumpToLevel(3)"
-          >{{ tempStreet || '街道' }}</text>
+          <block v-if="effectiveLevels >= 4">
+            <text v-if="tempDistrict" class="crumb-arrow">›</text>
+            <text
+              :class="['crumb', currentLevel === 3 ? 'crumb-active' : '']"
+              @click="jumpToLevel(3)"
+            >{{ tempStreet || '街道' }}</text>
+          </block>
         </view>
 
         <!-- 选择列表 -->
@@ -60,7 +62,7 @@
 </template>
 
 <script>
-import { getProvinces, getStreetChildren } from '@/utils/api.js'
+import { getProvinces, getStreetChildren, getStreetPath } from '@/utils/api.js'
 
 export default {
   name: 'AddressPicker',
@@ -68,6 +70,21 @@ export default {
     value: {
       type: Number,
       default: null
+    },
+    // 联动层级:3=省/市/区县(收货地址等,详细地址另行输入);4=省/市/区县/街道(默认)
+    levels: {
+      type: Number,
+      default: 4
+    },
+    // 是否渲染组件自带的入口行;页面已有自己的表单行时传 false,通过 ref.open() 打开
+    showTrigger: {
+      type: Boolean,
+      default: true
+    },
+    // 编辑回显用:{ province, city, district } 名称,打开面板时按名称回选
+    initial: {
+      type: Object,
+      default: null
     }
   },
   data() {
@@ -90,6 +107,11 @@ export default {
     }
   },
   computed: {
+    // 支持 3 级(省市区)与 4 级(省市区街道),外部传入越界值时兜底
+    effectiveLevels() {
+      var n = Number(this.levels) || 4
+      return Math.min(4, Math.max(3, n))
+    },
     currentItems() {
       if (this.currentLevel === 0) return this.provinces
       if (this.currentLevel === 1) return this.cities
@@ -108,83 +130,124 @@ export default {
       }
     }
   },
-  mounted() {
-    this.loadProvinces()
-  },
   methods: {
     async loadProvinces() {
+      this.loading = true
       try {
         const res = await getProvinces()
         this.provinces = res.data || []
       } catch (e) {
         console.error('加载省份失败', e)
+      } finally {
+        this.loading = false
       }
     },
 
     async loadCities(provinceId) {
+      this.loading = true
       try {
         const res = await getStreetChildren(provinceId)
         this.cities = res.data || []
       } catch (e) {
         console.error('加载城市失败', e)
+      } finally {
+        this.loading = false
       }
     },
 
     async loadDistricts(cityId) {
+      this.loading = true
       try {
         const res = await getStreetChildren(cityId)
         this.districts = res.data || []
       } catch (e) {
         console.error('加载区县失败', e)
+      } finally {
+        this.loading = false
       }
     },
 
     async loadStreets(districtId) {
+      this.loading = true
       try {
         const res = await getStreetChildren(districtId)
         this.streets = res.data || []
       } catch (e) {
         console.error('加载街道失败', e)
+      } finally {
+        this.loading = false
       }
     },
 
     async loadAddressById(streetId) {
       try {
-        const res = await getStreetChildren(streetId)
+        // 必须用「完整路径」接口:/api/streets/{id}/path 返回 [省, 市, 区县, 街道]
+        const res = await getStreetPath(streetId)
         const path = res.data || []
 
-        if (path.length >= 4) {
+        if (path.length >= 3) {
           this.tempProvince = path[0]
           this.tempCity = path[1]
           this.tempDistrict = path[2]
-          this.tempStreet = path[3]
-          this.currentAddress = path[3].fullName || this.buildAddress(path)
+          this.tempStreet = path[3] || null
+          this.updateAddressText()
         }
 
         // 加载下级选项
         if (this.tempProvince) await this.loadCities(this.tempProvince.id)
         if (this.tempCity) await this.loadDistricts(this.tempCity.id)
-        if (this.tempDistrict) await this.loadStreets(this.tempDistrict.id)
+        if (this.tempDistrict && this.effectiveLevels >= 4) await this.loadStreets(this.tempDistrict.id)
       } catch (e) {
         console.error('加载地址路径失败', e)
       }
     },
 
-    buildAddress(path) {
-      if (!path || path.length === 0) return ''
-      var parts = []
-      if (path[0]) parts.push(path[0].province || path[0].name)
-      if (path[1]) parts.push(path[1].city || path[1].name)
-      if (path[2]) parts.push(path[2].district || path[2].name)
-      if (path[3]) parts.push(path[3].street || path[3].name)
-      return parts.join('')
+    // 对外入口:页面可用 ref.open() 打开面板(配合 show-trigger=false)
+    async open() {
+      this.showPicker = true
+      if (this.provinces.length === 0) {
+        await this.loadProvinces()
+      }
+      // 编辑回显:按传入的名称逐级回选
+      if (this.initial && !this.tempProvince) {
+        await this.applyInitial()
+      }
     },
 
+    // 兼容旧调用名
     openPicker() {
-      this.showPicker = true
-      if (!this.tempProvince && this.provinces.length === 0) {
-        this.loadProvinces()
+      return this.open()
+    },
+
+    async applyInitial() {
+      var init = this.initial || {}
+      if (!init.province) return
+
+      var p = this.provinces.find((x) => this.getItemName(x) === init.province)
+      if (!p) return
+      this.tempProvince = p
+      this.currentLevel = 1
+      await this.loadCities(p.id)
+
+      if (!init.city) return
+      var c = this.cities.find((x) => this.getItemName(x) === init.city)
+      if (!c) return
+      this.tempCity = c
+      this.currentLevel = 2
+      await this.loadDistricts(c.id)
+
+      if (!init.district) return
+      var d = this.districts.find((x) => this.getItemName(x) === init.district)
+      if (!d) return
+      this.tempDistrict = d
+
+      // 三级模式:停在区县列表,当前区县保持高亮,不再加载街道
+      if (this.effectiveLevels <= 3) {
+        this.currentLevel = 2
+        return
       }
+      this.currentLevel = 3
+      await this.loadStreets(d.id)
     },
 
     closePicker() {
@@ -220,8 +283,13 @@ export default {
         this.tempDistrict = item
         this.tempStreet = null
         this.streets = []
-        this.currentLevel = 3
-        this.loadStreets(item.id)
+        if (this.effectiveLevels <= 3) {
+          // 三级模式:选完区县即完成
+          this.confirmAddress()
+        } else {
+          this.currentLevel = 3
+          this.loadStreets(item.id)
+        }
       } else if (this.currentLevel === 3) {
         this.tempStreet = item
         this.confirmAddress()
@@ -229,6 +297,13 @@ export default {
     },
 
     confirmAddress() {
+      // 三级模式必须选完整省/市/区县,避免只选省就确定把已存地区清空
+      if (this.effectiveLevels <= 3) {
+        if (!this.tempProvince || !this.tempCity || !this.tempDistrict) {
+          uni.showToast({ title: '请选择省/市/区县', icon: 'none' })
+          return
+        }
+      }
       this.updateAddressText()
       this.emitChange()
       this.closePicker()
@@ -239,18 +314,21 @@ export default {
       if (this.tempProvince) parts.push(this.tempProvince.province || this.tempProvince.name)
       if (this.tempCity) parts.push(this.tempCity.city || this.tempCity.name)
       if (this.tempDistrict) parts.push(this.tempDistrict.district || this.tempDistrict.name)
-      if (this.tempStreet) parts.push(this.tempStreet.street || this.tempStreet.name)
+      if (this.effectiveLevels >= 4 && this.tempStreet) {
+        parts.push(this.tempStreet.street || this.tempStreet.name)
+      }
       this.currentAddress = parts.join('')
     },
 
     emitChange() {
-      const streetId = this.tempStreet ? this.tempStreet.id : null
+      var hasStreet = this.effectiveLevels >= 4 && !!this.tempStreet
+      const streetId = hasStreet ? this.tempStreet.id : null
       const addressData = {
         streetId: streetId,
         province: this.tempProvince ? this.tempProvince.province : null,
         city: this.tempCity ? this.tempCity.city : null,
         district: this.tempDistrict ? this.tempDistrict.district : null,
-        street: this.tempStreet ? this.tempStreet.street : null,
+        street: hasStreet ? this.tempStreet.street : null,
         fullName: this.currentAddress
       }
 

+ 134 - 14
cfc-frontend/pages/profile/service-role-apply.vue

@@ -34,17 +34,29 @@
 
       <view class="section">
         <view class="section-title">选择申请角色 <text class="required">*</text></view>
+        <!-- 严格三列:roleRows 每行固定 3 个单元格,flex:1 等分,不依赖 calc/gap -->
         <view class="role-grid">
           <view
-            v-for="role in roleList"
-            :key="role.key"
-            class="role-card"
-            :class="{ selected: selectedRoles.indexOf(role.key) > -1 }"
-            @click="toggleRole(role.key)"
+            v-for="(row, rowIdx) in roleRows"
+            :key="getRoleRowKey(rowIdx)"
+            class="role-row"
           >
-            <text class="role-icon">{{ role.icon }}</text>
-            <text class="role-name">{{ role.name }}</text>
-            <view v-if="selectedRoles.indexOf(role.key) > -1" class="check-mark">✓</view>
+            <view
+              v-for="role in row"
+              :key="role.key"
+              class="role-cell"
+            >
+              <view
+                v-if="!role.placeholder"
+                class="role-card"
+                :class="{ selected: selectedRoles.indexOf(role.key) > -1 }"
+                @click="toggleRole(role.key)"
+              >
+                <text class="role-icon">{{ role.icon }}</text>
+                <text class="role-name">{{ role.name }}</text>
+                <view v-if="selectedRoles.indexOf(role.key) > -1" class="check-mark">✓</view>
+              </view>
+            </view>
           </view>
         </view>
       </view>
@@ -86,8 +98,17 @@
 
       <view class="section">
         <view class="section-title">联系地址 <text class="required">*</text></view>
-        <AddressPicker ref="addressPicker" @change="onAddressChange" />
+        <view class="address-row" @click="openAddressPicker">
+          <text class="address-label">所在地区</text>
+          <view class="address-value-wrap">
+            <text v-if="addressText" class="address-value">{{ addressText }}</text>
+            <text v-else class="address-placeholder">请选择省市区街道</text>
+            <text class="address-arrow">›</text>
+          </view>
+        </view>
         <input class="input" v-model="form.addressDetail" placeholder="详细地址(门牌号等)" />
+        <!-- 与收件人地址共用同一组件 components/address-picker.vue -->
+        <AddressPicker ref="addressPicker" :show-trigger="false" @change="onAddressChange" />
       </view>
 
       <button class="submit-btn" :disabled="submitting" @click="handleSubmit">
@@ -102,7 +123,7 @@
 
 <script>
 import { submitServiceRoleApply, getMyServiceRoleApply, uploadServiceRoleImage } from '../../utils/api'
-import AddressPicker from '../../components/address-picker.vue'
+import AddressPicker from '@/components/address-picker.vue'
 
 export default {
   components: { AddressPicker },
@@ -117,6 +138,7 @@ export default {
         { key: 'activity_provider', name: '活动提供商', icon: '🎪' }
       ],
       selectedRoles: [],
+      addressText: '',
       form: {
         idCard: '',
         idCardFront: '',
@@ -134,6 +156,25 @@ export default {
     }
   },
   computed: {
+    // 按每行 3 个拆分行;最后一行不足 3 个用空占位单元格补齐,保证各行严格三列等宽
+    roleRows() {
+      const rows = []
+      const list = this.roleList || []
+      const total = Math.ceil(list.length / 3) * 3
+      for (let i = 0; i < total; i += 3) {
+        const row = []
+        for (let j = 0; j < 3; j++) {
+          const item = list[i + j]
+          row.push(
+            item
+              ? item
+              : { key: 'role-placeholder-' + i + '-' + j, name: '', icon: '', placeholder: true }
+          )
+        }
+        rows.push(row)
+      }
+      return rows
+    },
     applyStatus() {
       if (!this.applyRecord) return 'none'
       const s = this.applyRecord.status
@@ -251,11 +292,20 @@ export default {
     getCertKey(idx) {
       return 'cert-' + idx
     },
+    getRoleRowKey(rowIdx) {
+      return 'role-row-' + rowIdx
+    },
+    openAddressPicker() {
+      if (this.$refs.addressPicker) {
+        this.$refs.addressPicker.open()
+      }
+    },
     onAddressChange(e) {
       this.form.province = e.province || ''
       this.form.city = e.city || ''
       this.form.district = e.district || ''
       this.form.street = e.street || ''
+      this.addressText = e.fullName || ''
     },
     async handleSubmit() {
       if (this.selectedRoles.length === 0) {
@@ -304,6 +354,7 @@ export default {
       this.applyRecord = null
       this.showStatusView = false
       this.selectedRoles = []
+      this.addressText = ''
       this.form = {
         idCard: '',
         idCardFront: '',
@@ -469,14 +520,35 @@ export default {
   margin-left: 4rpx;
 }
 
-/* 角色网格 */
+/* 角色网格:固定三列
+   不使用 calc() 与 flex gap(旧版 iOS WebView 不支持 gap,calc 除法在不同机型上
+   会出现取整偏差导致换行)——改用「每行 3 个 flex:1 单元格 + 固定 rpx 间距」,
+   所有机型/分辨率下都严格等分三列 */
 .role-grid {
   display: flex;
-  flex-wrap: wrap;
-  gap: 20rpx;
+  flex-direction: column;
+}
+.role-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+.role-cell {
+  /* flex:1 + 可收缩,保证同一行三等分,长文案不撑破列宽 */
+  flex: 1;
+  min-width: 0;
+  box-sizing: border-box;
+}
+/* 单元格之间留白:左右各 8rpx,首尾行视情况抵消,保证总宽不溢出 */
+.role-cell:not(:first-child) {
+  margin-left: 16rpx;
+}
+.role-row:not(:first-child) {
+  margin-top: 20rpx;
 }
 .role-card {
-  width: calc((100% - 40rpx) / 3);
+  width: 100%;
+  box-sizing: border-box;
   background: #F9FAFB;
   border-radius: 16rpx;
   padding: 30rpx 0;
@@ -499,6 +571,11 @@ export default {
   font-size: 24rpx;
   color: #374151;
   font-weight: 500;
+  /* 保证长角色名不换行撑高导致行高不一致 */
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  max-width: 100%;
 }
 .check-mark {
   position: absolute;
@@ -509,6 +586,49 @@ export default {
   font-weight: bold;
 }
 
+/* 联系地址入口行(与收件人地址页保持同一套视觉) */
+.address-row {
+  display: flex;
+  align-items: center;
+  padding: 24rpx 24rpx;
+  background: #FAFAFA;
+  border: 2rpx solid #E5E7EB;
+  border-radius: 12rpx;
+  margin-bottom: 16rpx;
+}
+.address-label {
+  font-size: 26rpx;
+  color: #374151;
+  width: 140rpx;
+  flex-shrink: 0;
+}
+.address-value-wrap {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  min-width: 0;
+}
+.address-value {
+  font-size: 26rpx;
+  color: #374151;
+  text-align: right;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.address-placeholder {
+  font-size: 26rpx;
+  color: #9CA3AF;
+  text-align: right;
+}
+.address-arrow {
+  font-size: 34rpx;
+  color: #9CA3AF;
+  margin-left: 8rpx;
+  flex-shrink: 0;
+}
+
 /* 输入框 */
 .input {
   width: 100%;

+ 25 - 279
cfc-frontend/pages/shop/address-edit/address-edit.vue

@@ -57,39 +57,14 @@
         </view>
       </view>
 
-      <!-- 区域选择弹窗 -->
-      <view class="region-mask" v-if="showRegionPicker" @click="cancelRegion">
-        <view class="region-picker" @click.stop>
-          <view class="region-header">
-            <text class="region-header-btn" @click="cancelRegion">取消</text>
-            <text class="region-header-title">选择地区</text>
-            <text class="region-header-btn region-confirm" @click="confirmRegion">确定</text>
-          </view>
-          <view class="region-breadcrumb">
-            <text
-              v-for="(name, i) in regionBreadcrumb"
-              :key="i"
-              :class="['crumb-item', i === currentRegionLevel ? 'crumb-active' : '']"
-              @click="jumpRegionLevel(i)"
-            >{{ name || '请选择' }}</text>
-            <text class="crumb-arrow" v-if="currentRegionLevel < 2">›</text>
-          </view>
-          <scroll-view class="region-list" scroll-y>
-            <view
-              v-for="(item, idx) in currentRegionItems"
-              :key="idx"
-              :class="['region-item', isRegionSelected(item) ? 'region-item-selected' : '']"
-              @click="selectRegionItem(item)"
-            >
-              <text class="region-item-text">{{ getRegionName(item) }}</text>
-              <text class="region-item-check" v-if="isRegionSelected(item)">✓</text>
-            </view>
-            <view v-if="regionLoading" class="region-loading">
-              <text>加载中...</text>
-            </view>
-          </scroll-view>
-        </view>
-      </view>
+      <!-- 区域选择弹窗:统一使用 components/address-picker.vue(3 级省市区) -->
+      <address-picker
+        ref="addressPicker"
+        :levels="3"
+        :show-trigger="false"
+        :initial="regionInitial"
+        @change="onRegionChange"
+      />
 
       <!-- Save Button -->
       <button
@@ -105,8 +80,10 @@
 
 <script>
 import config from '@/config.js'
+import AddressPicker from '@/components/address-picker.vue'
 
 export default {
+  components: { AddressPicker },
   data() {
     return {
       addressId: null,
@@ -120,17 +97,7 @@ export default {
         street: '',
         isDefault: 0,
         fullAddress: ''
-      },
-      // 区域选择弹窗
-      showRegionPicker: false,
-      currentRegionLevel: 0, // 0=province, 1=city, 2=district
-      regionLoading: false,
-      provinceList: [],
-      cityList: [],
-      districtList: [],
-      tempProvince: null,
-      tempCity: null,
-      tempDistrict: null
+      }
     }
   },
   onLoad(options) {
@@ -143,18 +110,13 @@ export default {
     }
   },
   computed: {
-    regionBreadcrumb() {
-      var names = []
-      names.push(this.tempProvince ? this.getRegionName(this.tempProvince) : '省份')
-      names.push(this.tempCity ? this.getRegionName(this.tempCity) : '城市')
-      names.push(this.tempDistrict ? this.getRegionName(this.tempDistrict) : '区县')
-      return names
-    },
-    currentRegionItems() {
-      if (this.currentRegionLevel === 0) return this.provinceList
-      if (this.currentRegionLevel === 1) return this.cityList
-      if (this.currentRegionLevel === 2) return this.districtList
-      return []
+    // 编辑回显:把已保存的省/市/区县名称传给统一地址组件,打开时按名称回选
+    regionInitial() {
+      return {
+        province: this.form.province || '',
+        city: this.form.city || '',
+        district: this.form.district || ''
+      }
     }
   },
   methods: {
@@ -183,135 +145,17 @@ export default {
         }
       })
     },
-    // ===== 区域选择弹窗 =====
+    // ===== 区域选择(统一组件 components/address-picker.vue) =====
     openRegionPicker() {
-      this.tempProvince = this.form.province ? { name: this.form.province, id: null } : null
-      this.tempCity = this.form.city ? { name: this.form.city, id: null } : null
-      this.tempDistrict = this.form.district ? { name: this.form.district, id: null } : null
-      this.currentRegionLevel = 0
-      this.showRegionPicker = true
-      this.loadRegionProvinces()
-    },
-    loadRegionProvinces() {
-      var that = this
-      that.regionLoading = true
-      uni.request({
-        url: config.api('/api/region/provinces'),
-        method: 'POST',
-        header: {
-          'Content-Type': 'application/json',
-          'Authorization': 'Bearer ' + uni.getStorageSync('token')
-        },
-        success: function(res) {
-          that.regionLoading = false
-          if (res.data && res.data.code === 200) {
-            that.provinceList = res.data.data || []
-          }
-        },
-        fail: function() {
-          that.regionLoading = false
-        }
-      })
-    },
-    selectRegionItem(item) {
-      if (this.currentRegionLevel === 0) {
-        this.tempProvince = item
-        this.tempCity = null
-        this.tempDistrict = null
-        this.cityList = []
-        this.districtList = []
-        this.currentRegionLevel = 1
-        this.loadRegionCities(this.getRegionId(item))
-      } else if (this.currentRegionLevel === 1) {
-        this.tempCity = item
-        this.tempDistrict = null
-        this.districtList = []
-        this.currentRegionLevel = 2
-        this.loadRegionDistricts(this.getRegionId(item))
-      } else if (this.currentRegionLevel === 2) {
-        this.tempDistrict = item
-        this.confirmRegion()
+      if (this.$refs.addressPicker) {
+        this.$refs.addressPicker.open()
       }
     },
-    loadRegionCities(parentId) {
-      if (!parentId) return
-      var that = this
-      that.regionLoading = true
-      uni.request({
-        url: config.api('/api/region/cities'),
-        method: 'POST',
-        data: { parentId: parentId },
-        header: {
-          'Content-Type': 'application/json',
-          'Authorization': 'Bearer ' + uni.getStorageSync('token')
-        },
-        success: function(res) {
-          that.regionLoading = false
-          if (res.data && res.data.code === 200) {
-            that.cityList = res.data.data || []
-          }
-        },
-        fail: function() {
-          that.regionLoading = false
-        }
-      })
-    },
-    loadRegionDistricts(parentId) {
-      if (!parentId) return
-      var that = this
-      that.regionLoading = true
-      uni.request({
-        url: config.api('/api/region/districts'),
-        method: 'POST',
-        data: { parentId: parentId },
-        header: {
-          'Content-Type': 'application/json',
-          'Authorization': 'Bearer ' + uni.getStorageSync('token')
-        },
-        success: function(res) {
-          that.regionLoading = false
-          if (res.data && res.data.code === 200) {
-            that.districtList = res.data.data || []
-          }
-        },
-        fail: function() {
-          that.regionLoading = false
-        }
-      })
-    },
-    confirmRegion() {
-      if (this.tempProvince) {
-        this.form.province = this.getRegionName(this.tempProvince)
-      }
-      if (this.tempCity) {
-        this.form.city = this.getRegionName(this.tempCity)
-      }
-      if (this.tempDistrict) {
-        this.form.district = this.getRegionName(this.tempDistrict)
-      }
+    onRegionChange(data) {
+      this.form.province = data.province || ''
+      this.form.city = data.city || ''
+      this.form.district = data.district || ''
       this.updateFullAddress()
-      this.showRegionPicker = false
-    },
-    cancelRegion() {
-      this.showRegionPicker = false
-    },
-    jumpRegionLevel(level) {
-      if (level < this.currentRegionLevel) {
-        this.currentRegionLevel = level
-      }
-    },
-    getRegionName(item) {
-      return item && (item.name || item.province || item.city || item.district || '')
-    },
-    getRegionId(item) {
-      return item && (item.id || item.code)
-    },
-    isRegionSelected(item) {
-      var name = this.getRegionName(item)
-      if (this.currentRegionLevel === 0) return this.tempProvince && this.getRegionName(this.tempProvince) === name
-      if (this.currentRegionLevel === 1) return this.tempCity && this.getRegionName(this.tempCity) === name
-      if (this.currentRegionLevel === 2) return this.tempDistrict && this.getRegionName(this.tempDistrict) === name
-      return false
     },
     updateFullAddress() {
       this.form.fullAddress = (this.form.province || '') + (this.form.city || '') + (this.form.district || '') + (this.form.street || '')
@@ -487,102 +331,4 @@ export default {
   background: #ddd;
   color: #999;
 }
-
-/* ===== 区域选择弹窗 ===== */
-.region-mask {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.5);
-  z-index: 999;
-  display: flex;
-  align-items: flex-end;
-}
-.region-picker {
-  background: #fff;
-  border-radius: 20rpx 20rpx 0 0;
-  width: 100%;
-  max-height: 70vh;
-  display: flex;
-  flex-direction: column;
-}
-.region-header {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 24rpx 30rpx;
-  border-bottom: 1rpx solid #f0f0f0;
-}
-.region-header-btn {
-  font-size: 28rpx;
-  color: #999;
-}
-.region-confirm {
-  color: #F97316;
-  font-weight: bold;
-}
-.region-header-title {
-  font-size: 30rpx;
-  font-weight: bold;
-  color: #333;
-}
-.region-breadcrumb {
-  display: flex;
-  align-items: center;
-  padding: 20rpx 30rpx;
-  border-bottom: 1rpx solid #f5f5f5;
-}
-.crumb-item {
-  font-size: 26rpx;
-  color: #999;
-  padding: 8rpx 12rpx;
-  border-radius: 8rpx;
-}
-.crumb-item.crumb-active {
-  color: #F97316;
-  font-weight: bold;
-}
-.crumb-arrow {
-  font-size: 24rpx;
-  color: #ccc;
-  margin: 0 8rpx;
-}
-.region-list {
-  max-height: 50vh;
-  overflow-y: auto;
-}
-.region-item {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 28rpx 30rpx;
-  border-bottom: 1rpx solid #f5f5f5;
-}
-.region-item:active {
-  background: #FFF7ED;
-}
-.region-item-text {
-  font-size: 28rpx;
-  color: #333;
-}
-.region-item-check {
-  font-size: 28rpx;
-  color: #F97316;
-  font-weight: bold;
-}
-.region-item-selected {
-  background: #FFF7ED;
-}
-.region-item-selected .region-item-text {
-  color: #F97316;
-  font-weight: bold;
-}
-.region-loading {
-  padding: 40rpx;
-  text-align: center;
-  font-size: 26rpx;
-  color: #999;
-}
 </style>

+ 25 - 280
cfc-frontend/pages/user-edit/user-edit.vue

@@ -250,47 +250,23 @@
 			<FamilyEmptyState type="card" />
 		</view>
 
-		<!-- 区域选择弹窗(省市区三级联动) -->
-		<view class="region-mask" v-if="showRegionPicker" @click="cancelRegion">
-			<view class="region-picker" @click.stop>
-				<view class="region-header">
-					<text class="region-header-btn" @click="cancelRegion">取消</text>
-					<text class="region-header-title">选择地区</text>
-					<text class="region-header-btn region-confirm" @click="confirmRegion">确定</text>
-				</view>
-				<view class="region-breadcrumb">
-					<text
-						v-for="(name, i) in regionBreadcrumb"
-						:key="i"
-						:class="['crumb-item', i === currentRegionLevel ? 'crumb-active' : '']"
-						@click="jumpRegionLevel(i)"
-					>{{ name || '请选择' }}</text>
-					<text class="crumb-arrow" v-if="currentRegionLevel < 2">›</text>
-				</view>
-				<scroll-view class="region-list" scroll-y>
-					<view
-						v-for="(item, idx) in currentRegionItems"
-						:key="idx"
-						:class="['region-item', isRegionSelected(item) ? 'region-item-selected' : '']"
-						@click="selectRegionItem(item)"
-					>
-						<text class="region-item-text">{{ getRegionName(item) }}</text>
-						<text class="region-item-check" v-if="isRegionSelected(item)">✓</text>
-					</view>
-					<view v-if="regionLoading" class="region-loading">
-						<text>加载中...</text>
-					</view>
-				</scroll-view>
-			</view>
-		</view>
+		<!-- 区域选择弹窗:统一使用 components/address-picker.vue(3 级省市区) -->
+		<address-picker
+			ref="addressPicker"
+			:levels="3"
+			:show-trigger="false"
+			:initial="regionInitial"
+			@change="onRegionChange"
+		/>
 	</view>
 </template>
 
 <script>
 import { updateUserInfo, createFamily, directRegister, getUserInfo, requestJoinByCode, getMyMembership, bindReferralIfNotSelf } from '../../utils/api.js'
-import config from '@/config.js'
+import AddressPicker from '@/components/address-picker.vue'
 
 export default {
+	components: { AddressPicker },
 	data() {
 		return {
 			isEdit: false,
@@ -325,16 +301,6 @@ export default {
 				district: '',
 				street: ''
 			},
-			// 区域选择弹窗(省市区三级联动,与订单收货人地址一致)
-			showRegionPicker: false,
-			currentRegionLevel: 0, // 0=省份, 1=城市, 2=区县
-			regionLoading: false,
-			provinceList: [],
-			cityList: [],
-			districtList: [],
-			tempProvince: null,
-			tempCity: null,
-			tempDistrict: null,
 			ethnicityOptions: ['汉族','蒙古族','回族','藏族','维吾尔族','苗族','彝族','壮族','布依族','朝鲜族','满族','侗族','瑶族','白族','土家族','哈尼族','哈萨克族','傣族','黎族','其他'],
 			bloodTypeOptions: ['A','B','AB','O','未知'],
 			birthHourOptions: ['子时(23-01)','丑时(01-03)','寅时(03-05)','卯时(05-07)','辰时(07-09)','巳时(09-11)','午时(11-13)','未时(13-15)','申时(15-17)','酉时(17-19)','戌时(19-21)','亥时(21-23)'],
@@ -375,19 +341,14 @@ export default {
       if (this.token) return true
       return this.$store.getters.hasFamily
     },
-    regionBreadcrumb: function() {
-      var names = []
-      names.push(this.tempProvince ? this.getRegionName(this.tempProvince) : '省份')
-      names.push(this.tempCity ? this.getRegionName(this.tempCity) : '城市')
-      names.push(this.tempDistrict ? this.getRegionName(this.tempDistrict) : '区县')
-      return names
-    },
-    currentRegionItems: function() {
-      if (this.currentRegionLevel === 0) return this.provinceList
-      if (this.currentRegionLevel === 1) return this.cityList
-      if (this.currentRegionLevel === 2) return this.districtList
-      return []
-    },
+    // 编辑回显:把已保存的省/市/区县名称传给统一地址组件,打开时按名称回选
+    regionInitial() {
+      return {
+        province: this.form.province || '',
+        city: this.form.city || '',
+        district: this.form.district || ''
+      }
+    }
   },
 	onLoad(options) {
 		// 注册零填写改造:本页仅保留「个人中心编辑资料」用途
@@ -486,135 +447,17 @@ export default {
 	goUpgrade() {
 		uni.navigateTo({ url: '/pages/membership/upgrade' })
 	},
-	// ===== 省市区三级联动弹窗(与订单收货人地址一致) =====
+	// ===== 区域选择(统一组件 components/address-picker.vue) =====
 	openRegionPicker() {
-		this.tempProvince = this.form.province ? { name: this.form.province, id: null } : null
-		this.tempCity = this.form.city ? { name: this.form.city, id: null } : null
-		this.tempDistrict = this.form.district ? { name: this.form.district, id: null } : null
-		this.currentRegionLevel = 0
-		this.showRegionPicker = true
-		this.loadRegionProvinces()
-	},
-	loadRegionProvinces() {
-		var self = this
-		self.regionLoading = true
-		uni.request({
-			url: config.api('/api/region/provinces'),
-			method: 'POST',
-			header: {
-				'Content-Type': 'application/json',
-				'Authorization': 'Bearer ' + uni.getStorageSync('token')
-			},
-			success: function(res) {
-				self.regionLoading = false
-				if (res.data && res.data.code === 200) {
-					self.provinceList = res.data.data || []
-				}
-			},
-			fail: function() {
-				self.regionLoading = false
-			}
-		})
-	},
-	loadRegionCities(parentId) {
-		if (!parentId) return
-		var self = this
-		self.regionLoading = true
-		uni.request({
-			url: config.api('/api/region/cities'),
-			method: 'POST',
-			data: { parentId: parentId },
-			header: {
-				'Content-Type': 'application/json',
-				'Authorization': 'Bearer ' + uni.getStorageSync('token')
-			},
-			success: function(res) {
-				self.regionLoading = false
-				if (res.data && res.data.code === 200) {
-					self.cityList = res.data.data || []
-				}
-			},
-			fail: function() {
-				self.regionLoading = false
-			}
-		})
-	},
-	loadRegionDistricts(parentId) {
-		if (!parentId) return
-		var self = this
-		self.regionLoading = true
-		uni.request({
-			url: config.api('/api/region/districts'),
-			method: 'POST',
-			data: { parentId: parentId },
-			header: {
-				'Content-Type': 'application/json',
-				'Authorization': 'Bearer ' + uni.getStorageSync('token')
-			},
-			success: function(res) {
-				self.regionLoading = false
-				if (res.data && res.data.code === 200) {
-					self.districtList = res.data.data || []
-				}
-			},
-			fail: function() {
-				self.regionLoading = false
-			}
-		})
-	},
-	selectRegionItem(item) {
-		if (this.currentRegionLevel === 0) {
-			this.tempProvince = item
-			this.tempCity = null
-			this.tempDistrict = null
-			this.cityList = []
-			this.districtList = []
-			this.currentRegionLevel = 1
-			this.loadRegionCities(this.getRegionId(item))
-		} else if (this.currentRegionLevel === 1) {
-			this.tempCity = item
-			this.tempDistrict = null
-			this.districtList = []
-			this.currentRegionLevel = 2
-			this.loadRegionDistricts(this.getRegionId(item))
-		} else if (this.currentRegionLevel === 2) {
-			this.tempDistrict = item
-			this.confirmRegion()
+		if (this.$refs.addressPicker) {
+			this.$refs.addressPicker.open()
 		}
 	},
-	confirmRegion() {
-		if (this.tempProvince) {
-			this.form.province = this.getRegionName(this.tempProvince)
-		}
-		if (this.tempCity) {
-			this.form.city = this.getRegionName(this.tempCity)
-		}
-		if (this.tempDistrict) {
-			this.form.district = this.getRegionName(this.tempDistrict)
-		}
+	onRegionChange(data) {
+		this.form.province = data.province || ''
+		this.form.city = data.city || ''
+		this.form.district = data.district || ''
 		this.updateFullAddress()
-		this.showRegionPicker = false
-	},
-	cancelRegion() {
-		this.showRegionPicker = false
-	},
-	jumpRegionLevel(level) {
-		if (level < this.currentRegionLevel) {
-			this.currentRegionLevel = level
-		}
-	},
-	getRegionName(item) {
-		return item && (item.name || item.province || item.city || item.district || '')
-	},
-	getRegionId(item) {
-		return item && (item.id || item.code)
-	},
-	isRegionSelected(item) {
-		var name = this.getRegionName(item)
-		if (this.currentRegionLevel === 0) return this.tempProvince && this.getRegionName(this.tempProvince) === name
-		if (this.currentRegionLevel === 1) return this.tempCity && this.getRegionName(this.tempCity) === name
-		if (this.currentRegionLevel === 2) return this.tempDistrict && this.getRegionName(this.tempDistrict) === name
-		return false
 	},
 	updateFullAddress() {
 		this.form.address = (this.form.province || '') + (this.form.city || '') + (this.form.district || '') + (this.form.street || '')
@@ -1181,104 +1024,6 @@ bindReferralIfNotSelf(code).then(function(res) {
 	color: #999;
 }
 
-/* ===== 区域选择弹窗 ===== */
-.region-mask {
-	position: fixed;
-	top: 0;
-	left: 0;
-	right: 0;
-	bottom: 0;
-	background: rgba(0, 0, 0, 0.5);
-	z-index: 999;
-	display: flex;
-	align-items: flex-end;
-}
-.region-picker {
-	background: #fff;
-	border-radius: 20rpx 20rpx 0 0;
-	width: 100%;
-	max-height: 70vh;
-	display: flex;
-	flex-direction: column;
-}
-.region-header {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-	padding: 24rpx 30rpx;
-	border-bottom: 1rpx solid #f0f0f0;
-}
-.region-header-btn {
-	font-size: 28rpx;
-	color: #999;
-}
-.region-confirm {
-	color: #F97316;
-	font-weight: bold;
-}
-.region-header-title {
-	font-size: 30rpx;
-	font-weight: bold;
-	color: #333;
-}
-.region-breadcrumb {
-	display: flex;
-	align-items: center;
-	padding: 20rpx 30rpx;
-	border-bottom: 1rpx solid #f5f5f5;
-}
-.crumb-item {
-	font-size: 26rpx;
-	color: #999;
-	padding: 8rpx 12rpx;
-	border-radius: 8rpx;
-}
-.crumb-item.crumb-active {
-	color: #F97316;
-	font-weight: bold;
-}
-.crumb-arrow {
-	font-size: 24rpx;
-	color: #ccc;
-	margin: 0 8rpx;
-}
-.region-list {
-	max-height: 50vh;
-	overflow-y: auto;
-}
-.region-item {
-	display: flex;
-	justify-content: space-between;
-	align-items: center;
-	padding: 28rpx 30rpx;
-	border-bottom: 1rpx solid #f5f5f5;
-}
-.region-item:active {
-	background: #FFF7ED;
-}
-.region-item-text {
-	font-size: 28rpx;
-	color: #333;
-}
-.region-item-check {
-	font-size: 28rpx;
-	color: #F97316;
-	font-weight: bold;
-}
-.region-item-selected {
-	background: #FFF7ED;
-}
-.region-item-selected .region-item-text {
-	color: #F97316;
-	font-weight: bold;
-}
-.region-loading {
-	padding: 40rpx;
-	text-align: center;
-	font-size: 26rpx;
-	color: #999;
-}
-
 /* ===== 地址行(所在地区) ===== */
 .address-row {
 	display: flex;