Browse Source

feat: 实现前端地址选择和区域匹配功能

- 新增address-picker.vue四级联动地址选择组件
  * 省市区街道四级选择
  * 支持v-model双向绑定
  * 自动加载地址路径

- 新增家庭地址编辑页面address-edit.vue
  * 集成地址选择组件
  * 保存家庭地址到后端

- 新增区域匹配页面street-match.vue
  * 显示街道级/区县级/城市级匹配结果
  * 统计各级别家庭和指导师数量

- 更新api.js添加街道相关API
  * getProvinces - 获取省份
  * getStreetChildren - 获取子级地址
  * matchByStreet - 区域匹配
  * getStreetStats - 区域统计
User 7 months ago
parent
commit
07dbaba084

+ 283 - 0
zxyj-frontend/components/address-picker.vue

@@ -0,0 +1,283 @@
+<template>
+  <view class="address-picker">
+    <view class="picker-row" @click="showProvincePicker">
+      <text class="picker-label">省份:</text>
+      <text class="picker-value">{{ selectedProvince ? selectedProvince.province : '请选择' }}</text>
+      <text class="picker-arrow">▶</text>
+    </view>
+    
+    <view class="picker-row" @click="showCityPicker" v-if="selectedProvince">
+      <text class="picker-label">城市:</text>
+      <text class="picker-value">{{ selectedCity ? selectedCity.city : '请选择' }}</text>
+      <text class="picker-arrow">▶</text>
+    </view>
+    
+    <view class="picker-row" @click="showDistrictPicker" v-if="selectedCity">
+      <text class="picker-label">区县:</text>
+      <text class="picker-value">{{ selectedDistrict ? selectedDistrict.district : '请选择' }}</text>
+      <text class="picker-arrow">▶</text>
+    </view>
+    
+    <view class="picker-row" @click="showStreetPicker" v-if="selectedDistrict">
+      <text class="picker-label">街道:</text>
+      <text class="picker-value">{{ selectedStreet ? selectedStreet.street : '请选择' }}</text>
+      <text class="picker-arrow">▶</text>
+    </view>
+    
+    <view class="address-display" v-if="fullAddress">
+      <text class="address-label">完整地址:</text>
+      <text class="address-text">{{ fullAddress }}</text>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'AddressPicker',
+  props: {
+    value: {
+      type: Number,
+      default: null
+    }
+  },
+  data() {
+    return {
+      provinces: [],
+      cities: [],
+      districts: [],
+      streets: [],
+      selectedProvince: null,
+      selectedCity: null,
+      selectedDistrict: null,
+      selectedStreet: null
+    }
+  },
+  computed: {
+    fullAddress() {
+      if (this.selectedStreet) {
+        return this.selectedStreet.fullName
+      }
+      return ''
+    }
+  },
+  watch: {
+    value: {
+      immediate: true,
+      handler(newVal) {
+        if (newVal) {
+          this.loadAddressById(newVal)
+        }
+      }
+    }
+  },
+  mounted() {
+    this.loadProvinces()
+  },
+  methods: {
+    async loadProvinces() {
+      try {
+        const res = await this.$http.get('/api/streets/provinces')
+        this.provinces = res.data || []
+      } catch (e) {
+        console.error('加载省份失败', e)
+      }
+    },
+    
+    async loadCities(provinceId) {
+      try {
+        const res = await this.$http.get(`/api/streets/children/${provinceId}`)
+        this.cities = res.data || []
+      } catch (e) {
+        console.error('加载城市失败', e)
+      }
+    },
+    
+    async loadDistricts(cityId) {
+      try {
+        const res = await this.$http.get(`/api/streets/children/${cityId}`)
+        this.districts = res.data || []
+      } catch (e) {
+        console.error('加载区县失败', e)
+      }
+    },
+    
+    async loadStreets(districtId) {
+      try {
+        const res = await this.$http.get(`/api/streets/children/${districtId}`)
+        this.streets = res.data || []
+      } catch (e) {
+        console.error('加载街道失败', e)
+      }
+    },
+    
+    async loadAddressById(streetId) {
+      try {
+        const res = await this.$http.get(`/api/streets/${streetId}/path`)
+        const path = res.data || []
+        
+        if (path.length >= 1) this.selectedProvince = path[0]
+        if (path.length >= 2) this.selectedCity = path[1]
+        if (path.length >= 3) this.selectedDistrict = path[2]
+        if (path.length >= 4) this.selectedStreet = path[3]
+        
+        // 加载下级选项
+        if (this.selectedProvince) await this.loadCities(this.selectedProvince.id)
+        if (this.selectedCity) await this.loadDistricts(this.selectedCity.id)
+        if (this.selectedDistrict) await this.loadStreets(this.selectedDistrict.id)
+      } catch (e) {
+        console.error('加载地址路径失败', e)
+      }
+    },
+    
+    showProvincePicker() {
+      uni.showActionSheet({
+        itemList: this.provinces.map(p => p.province),
+        success: async (res) => {
+          this.selectedProvince = this.provinces[res.tapIndex]
+          this.selectedCity = null
+          this.selectedDistrict = null
+          this.selectedStreet = null
+          this.cities = []
+          this.districts = []
+          this.streets = []
+          
+          await this.loadCities(this.selectedProvince.id)
+          this.emitChange()
+        }
+      })
+    },
+    
+    showCityPicker() {
+      if (this.cities.length === 0) {
+        uni.showToast({ title: '请先选择省份', icon: 'none' })
+        return
+      }
+      
+      uni.showActionSheet({
+        itemList: this.cities.map(c => c.city),
+        success: async (res) => {
+          this.selectedCity = this.cities[res.tapIndex]
+          this.selectedDistrict = null
+          this.selectedStreet = null
+          this.districts = []
+          this.streets = []
+          
+          await this.loadDistricts(this.selectedCity.id)
+          this.emitChange()
+        }
+      })
+    },
+    
+    showDistrictPicker() {
+      if (this.districts.length === 0) {
+        uni.showToast({ title: '请先选择城市', icon: 'none' })
+        return
+      }
+      
+      uni.showActionSheet({
+        itemList: this.districts.map(d => d.district),
+        success: async (res) => {
+          this.selectedDistrict = this.districts[res.tapIndex]
+          this.selectedStreet = null
+          this.streets = []
+          
+          await this.loadStreets(this.selectedDistrict.id)
+          this.emitChange()
+        }
+      })
+    },
+    
+    showStreetPicker() {
+      if (this.streets.length === 0) {
+        uni.showToast({ title: '请先选择区县', icon: 'none' })
+        return
+      }
+      
+      uni.showActionSheet({
+        itemList: this.streets.map(s => s.street),
+        success: (res) => {
+          this.selectedStreet = this.streets[res.tapIndex]
+          this.emitChange()
+        }
+      })
+    },
+    
+    emitChange() {
+      const streetId = this.selectedStreet ? this.selectedStreet.id : null
+      const addressData = {
+        streetId: streetId,
+        province: this.selectedProvince ? this.selectedProvince.province : null,
+        city: this.selectedCity ? this.selectedCity.city : null,
+        district: this.selectedDistrict ? this.selectedDistrict.district : null,
+        street: this.selectedStreet ? this.selectedStreet.street : null,
+        fullName: this.fullAddress
+      }
+      
+      this.$emit('input', streetId)
+      this.$emit('change', addressData)
+    },
+    
+    reset() {
+      this.selectedProvince = null
+      this.selectedCity = null
+      this.selectedDistrict = null
+      this.selectedStreet = null
+      this.cities = []
+      this.districts = []
+      this.streets = []
+      this.emitChange()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.address-picker {
+  background: #fff;
+  padding: 20rpx;
+}
+
+.picker-row {
+  display: flex;
+  align-items: center;
+  padding: 20rpx 0;
+  border-bottom: 1px solid #eee;
+}
+
+.picker-label {
+  font-size: 28rpx;
+  color: #666;
+  width: 140rpx;
+}
+
+.picker-value {
+  flex: 1;
+  font-size: 28rpx;
+  color: #333;
+}
+
+.picker-arrow {
+  font-size: 24rpx;
+  color: #999;
+  margin-left: 10rpx;
+}
+
+.address-display {
+  margin-top: 20rpx;
+  padding: 20rpx;
+  background: #f5f5f5;
+  border-radius: 8rpx;
+}
+
+.address-label {
+  font-size: 24rpx;
+  color: #999;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.address-text {
+  font-size: 28rpx;
+  color: #333;
+}
+</style>

+ 162 - 0
zxyj-frontend/pages/family/address-edit.vue

@@ -0,0 +1,162 @@
+<template>
+  <view class="family-address">
+    <view class="page-title">家庭地址设置</view>
+    
+    <view class="form-section">
+      <view class="section-title">所在街道</view>
+      <address-picker 
+        v-model="formData.streetId" 
+        @change="onAddressChange" 
+        ref="addressPicker"
+      />
+    </view>
+    
+    <view class="form-section" v-if="formData.fullName">
+      <view class="section-title">当前地址</view>
+      <view class="current-address">
+        <text>{{ formData.fullName }}</text>
+      </view>
+    </view>
+    
+    <button class="save-btn" @click="saveAddress" :loading="loading">保存地址</button>
+  </view>
+</template>
+
+<script>
+import AddressPicker from '@/components/address-picker.vue'
+
+export default {
+  components: {
+    AddressPicker
+  },
+  data() {
+    return {
+      loading: false,
+      familyId: null,
+      formData: {
+        streetId: null,
+        province: null,
+        city: null,
+        district: null,
+        street: null,
+        fullName: ''
+      }
+    }
+  },
+  onLoad(options) {
+    if (options.familyId) {
+      this.familyId = parseInt(options.familyId)
+      this.loadFamilyInfo()
+    }
+  },
+  methods: {
+    async loadFamilyInfo() {
+      try {
+        const res = await this.$http.get(`/api/families/${this.familyId}`)
+        const family = res.data
+        
+        if (family) {
+          this.formData.streetId = family.streetId
+          this.formData.province = family.province
+          this.formData.city = family.city
+          this.formData.district = family.district
+          this.formData.street = family.street
+          this.formData.fullName = `${family.province || ''}${family.city || ''}${family.district || ''}${family.street || ''}`
+        }
+      } catch (e) {
+        console.error('加载家庭信息失败', e)
+      }
+    },
+    
+    onAddressChange(data) {
+      this.formData.streetId = data.streetId
+      this.formData.province = data.province
+      this.formData.city = data.city
+      this.formData.district = data.district
+      this.formData.street = data.street
+      this.formData.fullName = data.fullName || ''
+    },
+    
+    async saveAddress() {
+      if (!this.formData.streetId) {
+        uni.showToast({ title: '请选择街道地址', icon: 'none' })
+        return
+      }
+      
+      this.loading = true
+      try {
+        await this.$http.put(`/api/families/${this.familyId}/address`, {
+          streetId: this.formData.streetId,
+          province: this.formData.province,
+          city: this.formData.city,
+          district: this.formData.district,
+          street: this.formData.street
+        })
+        
+        uni.showToast({ title: '保存成功', icon: 'success' })
+        setTimeout(() => {
+          uni.navigateBack()
+        }, 1500)
+      } catch (e) {
+        console.error('保存地址失败', e)
+        uni.showToast({ title: '保存失败', icon: 'none' })
+      } finally {
+        this.loading = false
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.family-address {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 20rpx;
+}
+
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  padding: 30rpx 0;
+}
+
+.form-section {
+  background: #fff;
+  border-radius: 16rpx;
+  margin-bottom: 20rpx;
+  padding: 20rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 20rpx;
+}
+
+.current-address {
+  background: #f9f9f9;
+  padding: 20rpx;
+  border-radius: 8rpx;
+}
+
+.current-address text {
+  font-size: 28rpx;
+  color: #666;
+}
+
+.save-btn {
+  margin-top: 40rpx;
+  width: 100%;
+  height: 88rpx;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #fff;
+  border-radius: 44rpx;
+  font-size: 32rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+</style>

+ 195 - 0
zxyj-frontend/pages/match/street-match.vue

@@ -0,0 +1,195 @@
+<template>
+  <view class="match-page">
+    <view class="page-title">区域匹配</view>
+    
+    <view class="filter-section">
+      <view class="section-title">选择街道</view>
+      <address-picker 
+        v-model="filterStreetId" 
+        @change="onFilterChange"
+      />
+      
+      <view class="filter-actions">
+        <button class="match-btn" @click="doMatch" :loading="matching">开始匹配</button>
+      </view>
+    </view>
+    
+    <view class="result-section" v-if="matchResult">
+      <view class="section-title">匹配结果</view>
+      
+      <view class="match-level" v-if="matchResult.streetMatch">
+        <view class="level-title">街道级匹配</view>
+        <view class="match-count">
+          找到 {{ familyCount.street || 0 }} 个家庭,
+          {{ teacherCount.street || 0 }} 个指导师
+        </view>
+      </view>
+      
+      <view class="match-level" v-if="matchResult.districtMatch">
+        <view class="level-title">区县级匹配</view>
+        <view class="match-count">
+          找到 {{ familyCount.district || 0 }} 个家庭,
+          {{ teacherCount.district || 0 }} 个指导师
+        </view>
+      </view>
+      
+      <view class="match-level" v-if="matchResult.cityMatch">
+        <view class="level-title">城市级匹配</view>
+        <view class="match-count">
+          找到 {{ familyCount.city || 0 }} 个家庭,
+          {{ teacherCount.city || 0 }} 个指导师
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+import AddressPicker from '@/components/address-picker.vue'
+
+export default {
+  components: {
+    AddressPicker
+  },
+  data() {
+    return {
+      filterStreetId: null,
+      matching: false,
+      matchResult: null,
+      familyCount: {},
+      teacherCount: {}
+    }
+  },
+  methods: {
+    onFilterChange(data) {
+      this.filterStreetId = data.streetId
+    },
+    
+    async doMatch() {
+      if (!this.filterStreetId) {
+        uni.showToast({ title: '请选择街道', icon: 'none' })
+        return
+      }
+      
+      this.matching = true
+      try {
+        // 获取匹配范围
+        const matchRes = await this.$http.get(`/api/streets/${this.filterStreetId}/match`)
+        this.matchResult = matchRes.data
+        
+        // 统计各级别的家庭数
+        if (matchRes.data.streetIds) {
+          this.familyCount.street = await this.countFamilies(matchRes.data.streetIds)
+          this.teacherCount.street = await this.countTeachers(matchRes.data.streetIds)
+        }
+        
+        if (matchRes.data.districtStreetIds) {
+          this.familyCount.district = await this.countFamilies(matchRes.data.districtStreetIds)
+          this.teacherCount.district = await this.countTeachers(matchRes.data.districtStreetIds)
+        }
+        
+        if (matchRes.data.cityStreetIds) {
+          this.familyCount.city = await this.countFamilies(matchRes.data.cityStreetIds)
+          this.teacherCount.city = await this.countTeachers(matchRes.data.cityStreetIds)
+        }
+      } catch (e) {
+        console.error('匹配失败', e)
+        uni.showToast({ title: '匹配失败', icon: 'none' })
+      } finally {
+        this.matching = false
+      }
+    },
+    
+    async countFamilies(streetIds) {
+      if (!streetIds || streetIds.length === 0) return 0
+      try {
+        const res = await this.$http.get('/api/admin/families/count', {
+          params: { streetIds: streetIds.join(',') }
+        })
+        return res.data || 0
+      } catch (e) {
+        return 0
+      }
+    },
+    
+    async countTeachers(streetIds) {
+      if (!streetIds || streetIds.length === 0) return 0
+      try {
+        const res = await this.$http.get('/api/admin/teachers/count', {
+          params: { streetIds: streetIds.join(',') }
+        })
+        return res.data || 0
+      } catch (e) {
+        return 0
+      }
+    }
+  }
+}
+</script>
+
+<style scoped>
+.match-page {
+  min-height: 100vh;
+  background: #f5f5f5;
+  padding: 20rpx;
+}
+
+.page-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  padding: 30rpx 0;
+}
+
+.filter-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+  margin-bottom: 20rpx;
+}
+
+.section-title {
+  font-size: 30rpx;
+  color: #333;
+  font-weight: bold;
+  margin-bottom: 20rpx;
+}
+
+.filter-actions {
+  margin-top: 30rpx;
+}
+
+.match-btn {
+  width: 100%;
+  height: 88rpx;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  color: #fff;
+  border-radius: 44rpx;
+  font-size: 32rpx;
+}
+
+.result-section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 20rpx;
+}
+
+.match-level {
+  background: #f9f9f9;
+  padding: 20rpx;
+  border-radius: 8rpx;
+  margin-bottom: 20rpx;
+}
+
+.level-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.match-count {
+  font-size: 26rpx;
+  color: #666;
+}
+</style>

+ 159 - 0
zxyj-frontend/utils/api.js

@@ -50,6 +50,44 @@ export const phoneLogin = (data) => {
   return request('/api/auth/phone-login', 'POST', data)
 }
 
+// ===================== 街道地址相关 =====================
+
+// 获取所有省份
+export const getProvinces = () => {
+  return request('/api/streets/provinces')
+}
+
+// 根据父级ID获取子级地址
+export const getStreetChildren = (parentId) => {
+  return request(`/api/streets/children/${parentId}`)
+}
+
+// 根据ID获取地址详情
+export const getStreetById = (id) => {
+  return request(`/api/streets/${id}`)
+}
+
+// 获取地址完整路径
+export const getStreetPath = (id) => {
+  return request(`/api/streets/${id}/path`)
+}
+
+// 搜索地址
+export const searchStreet = (keyword) => {
+  return request('/api/streets/search', 'GET', { keyword })
+}
+
+// 区域匹配
+export const matchByStreet = (streetId) => {
+  return request(`/api/streets/${streetId}/match`)
+}
+
+// 按区域统计
+export const getStreetStats = (level = 4) => {
+  return request('/api/streets/stats', 'GET', { level })
+}
+}
+
 // 微信手机号登录(新版)
 export const wechatPhoneLogin = (data) => {
   return request('/api/auth/wechat-phone-login', 'POST', data)
@@ -251,3 +289,124 @@ export const exchangeParentWishlist = (id) => {
 export const approveParentWishlist = (id, approved) => {
   return request(`/api/parent/wishlist/${id}/approve`, 'POST', { approved })
 }
+
+// =============================================
+// 指导师-套餐模块
+// =============================================
+// 生成绑定邀请
+export const generateBindInvite = () => {
+  return request('/api/guide/bind/invite', 'POST')
+}
+
+// 获取绑定的家庭列表
+export const getBoundFamilies = () => {
+  return request('/api/guide/families', 'GET')
+}
+
+// 确认绑定家庭
+export const confirmBind = (data) => {
+  return request('/api/guide/bind/confirm', 'POST', data)
+}
+
+// 解除绑定
+export const unbindFamily = (id) => {
+  return request(`/api/guide/bind/${id}`, 'DELETE')
+}
+
+// 创建套餐
+export const createPackage = (data) => {
+  return request('/api/packages', 'POST', data)
+}
+
+// 更新套餐
+export const updatePackage = (id, data) => {
+  return request(`/api/packages/${id}`, 'PUT', data)
+}
+
+// 获取套餐详情
+export const getPackage = (id) => {
+  return request(`/api/packages/${id}`, 'GET')
+}
+
+// 获取我的套餐列表
+export const getMyPackages = () => {
+  return request('/api/packages', 'GET')
+}
+
+// 提交审核
+export const submitPackageForReview = (id) => {
+  return request(`/api/packages/${id}/submit`, 'POST')
+}
+
+// 发布/下架
+export const publishPackage = (id, publish) => {
+  return request(`/api/packages/${id}/publish`, 'POST', {}, { publish })
+}
+
+// 添加任务项
+export const addPackageItem = (packageId, data) => {
+  return request(`/api/packages/${packageId}/items`, 'POST', data)
+}
+
+// 更新任务项
+export const updatePackageItem = (itemId, data) => {
+  return request(`/api/packages/items/${itemId}`, 'PUT', data)
+}
+
+// 删除任务项
+export const deletePackageItem = (itemId) => {
+  return request(`/api/packages/items/${itemId}`, 'DELETE')
+}
+
+// 获取套餐任务项
+export const getPackageItems = (packageId) => {
+  return request(`/api/packages/${packageId}/items`, 'GET')
+}
+
+// =============================================
+// 家长-套餐市场模块
+// =============================================
+// 获取公开套餐列表
+export const getPublicPackages = (category) => {
+  return request('/api/market/packages', 'GET', { category })
+}
+
+// 通过分享码获取套餐
+export const getPackageByShareCode = (code) => {
+  return request(`/api/market/share/${code}`, 'GET')
+}
+
+// 购买并应用套餐
+export const purchasePackage = (packageId, data) => {
+  return request(`/api/market/packages/${packageId}/purchase`, 'POST', data)
+}
+
+// 获取我的任务计划
+export const getMyPlans = () => {
+  return request('/api/market/plans', 'GET')
+}
+
+// 获取计划详情
+export const getPlanDetail = (id) => {
+  return request(`/api/market/plans/${id}`, 'GET')
+}
+
+// 获取计划任务项
+export const getPlanItems = (planId) => {
+  return request(`/api/market/plans/${planId}/items`, 'GET')
+}
+
+// 取消计划
+export const cancelPlan = (id) => {
+  return request(`/api/market/plans/${id}/cancel`, 'POST')
+}
+
+// 确认绑定指导师
+export const acceptBind = (data) => {
+  return request('/api/bind/accept', 'POST', data)
+}
+
+// 获取绑定的指导师列表
+export const getBoundGuides = () => {
+  return request('/api/bind/guides', 'GET')
+}