Ver Fonte

feat(doctor-profile): 完善医生个人信息编辑功能

- 新增手机号输入框并添加格式校验
- 添加省市区选择器及自动定位功能
- 优化头像上传逻辑,支持上传状态提示
- 补充表单提交前的完整校验规则
- 调整页面 UI 布局与交互细节
- 修复部分组件状态更新时机问题
- 统一错误提示与加载状态管理
mcbaiyun há 1 mês atrás
pai
commit
ed6045555b

+ 308 - 92
src/pages/doctor/profile/infos/base-info.vue

@@ -15,6 +15,7 @@
             <view class="avatar-frame">
               <image v-if="form.avatar" class="avatar-img" :src="form.avatar" mode="aspectFill" />
               <text v-else class="avatar-placeholder">点击选择头像</text>
+              <text v-if="avatarUploading" class="avatar-uploading">上传中...</text>
             </view>
           </button>
         </view>
@@ -25,6 +26,11 @@
         <input class="input" type="nickname" v-model="form.nickname" :disabled="loading" placeholder="请输入姓名" />
       </view>
 
+      <view class="form-item">
+        <text class="label">手机号</text>
+        <input class="input" v-model="form.phone" :disabled="loading" placeholder="请输入手机号" type="number" />
+      </view>
+
       <view class="form-item">
         <text class="label">年龄</text>
         <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
@@ -44,8 +50,22 @@
         </view>
       </view>
 
+      <view class="form-item">
+        <text class="label">省/市</text>
+        <view class="location-section">
+          <picker mode="region" :value="region" :disabled="loading" @change="onRegionChange">
+            <view class="picker">{{ region.join(' ') || '请选择省/市' }}</view>
+          </picker>
+          <button class="get-location-btn" @click="getCurrentLocation" :disabled="loading || gettingLocation">
+            {{ gettingLocation ? '获取中...' : '使用当前定位' }}
+          </button>
+        </view>
+      </view>
+
+
+
       <view class="submit-section">
-        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : '提交' }}</button>
+        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : avatarUploading ? '头像上传中...' : '提交' }}</button>
       </view>
     </view>
   </view>
@@ -53,7 +73,6 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
-const isHttpUrl = (url: string) => /^https?:\/\//i.test(url)
 const loading = ref(false)
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
@@ -61,15 +80,20 @@ import CustomNav from '@/components/custom-nav.vue'
 const form = ref({
   avatar: '',
   nickname: '',
+  phone: '',
   age: '',
-  sex: 0
+  sex: 0,
+  address: ''
 })
 
+const region = ref<string[]>([])
 const submitting = ref(false)
 const isChoosing = ref(false)
-const avatarNeedsUpload = ref(false)
 const avatarUploading = ref(false)
+const avatarNeedsUpload = ref(false)
+// 标记用户是否主动选择/更改头像(用来控制是否上传)
 const avatarEditedByUser = ref(false)
+const gettingLocation = ref(false)
 
 // 从后端拉取已有用户信息并填充表单
 const fetchUserInfo = async () => {
@@ -77,6 +101,7 @@ const fetchUserInfo = async () => {
     const token = uni.getStorageSync('token')
     if (!token) return
     loading.value = true
+    // 使用 mask: true 阻止用户在加载/下载时继续交互
     uni.showLoading({ title: '加载中...', mask: true })
     const response = await uni.request({
       url: 'https://wx.baiyun.work/user_info',
@@ -87,7 +112,7 @@ const fetchUserInfo = async () => {
       },
       data: {}
     })
-    uni.hideLoading()
+    
     console.log('fetchUserInfo response:', response)
     if (response.statusCode === 401) {
       // 未授权,跳转登录
@@ -106,7 +131,9 @@ const fetchUserInfo = async () => {
           try {
             const downloadRes = await uni.downloadFile({
               url: `https://wx.baiyun.work/user/avatar/${userId}`,
-              header: { Authorization: `Bearer ${token}` }
+              header: {
+                Authorization: `Bearer ${token}`
+              }
             })
             if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
               d.avatar = downloadRes.tempFilePath
@@ -117,8 +144,12 @@ const fetchUserInfo = async () => {
         }
       }
       // 填充基本字段
-      form.value.avatar = d.avatar || form.value.avatar
+        form.value.avatar = d.avatar || form.value.avatar
+        // 完成从服务器获取资料后默认为不上传,等待用户主动修改头像
+        avatarNeedsUpload.value = false
+        avatarEditedByUser.value = false
       form.value.nickname = d.nickname || form.value.nickname
+      form.value.phone = d.phone || form.value.phone
       form.value.age = d.age || form.value.age
       // sex 可能为 'MALE'/'FEMALE' 或数字/1/2
       if (d.sex) {
@@ -129,13 +160,18 @@ const fetchUserInfo = async () => {
           form.value.sex = d.sex
         }
       }
+      // address 可能为 '重庆市 重庆市' 或包含空格,用空格或中文空格分割
+      if (d.address) {
+        const parts = String(d.address).split(/\s+/).filter(Boolean)
+        region.value = parts
+        form.value.address = parts.join(' ')
+      }
     }
-  } catch (err) {
+    } catch (err) {
     console.error('fetchUserInfo error:', err)
   } finally {
     uni.hideLoading()
     loading.value = false
-  
   }
 }
 
@@ -144,8 +180,108 @@ onShow(() => {
   fetchUserInfo()
 })
 
-const onChooseAvatar = (e: any) => {
+
+const isHttpUrl = (url: string) => /^https?:\/\//i.test(url)
+
+// 安全设置性别:在 loading 时禁止更改
+const setSex = (s: number) => {
   if (loading.value) return
+  form.value.sex = s
+}
+
+/**
+ * 上传头像到 `user/avatar/upload`,返回上传后的 URL(如果服务器返回)
+ */
+const uploadAvatar = (filePath: string) => {
+  return new Promise<string>((resolve, reject) => {
+    if (!filePath) {
+      reject(new Error('no filePath'))
+      return
+    }
+    avatarUploading.value = true
+    uni.showLoading({ title: '上传头像中...' })
+    const token = uni.getStorageSync('token')
+    uni.uploadFile({
+      url: 'https://wx.baiyun.work/user/avatar/upload',
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: `Bearer ${token}`
+      },
+      success: (uploadRes: any) => {
+        uni.hideLoading()
+        try {
+          const resp = JSON.parse(uploadRes.data || '{}') as any
+          if (resp && resp.code === 200) {
+            // 尝试从响应中取出 URL 字段(后端可能返回 data.url 或 data.path 等)
+            const uploadedUrl = resp.data?.url || resp.data?.path || resp.data?.fileUrl || ''
+            if (uploadedUrl) {
+              form.value.avatar = uploadedUrl
+            }
+            // 标记为已上传
+            avatarNeedsUpload.value = false
+            uni.showToast({ title: '头像上传成功', icon: 'success' })
+            resolve(uploadedUrl || '')
+          } else {
+            uni.showToast({ title: resp?.message || '上传失败', icon: 'none' })
+            reject(new Error(resp?.message || 'upload failed'))
+          }
+        } catch (err) {
+          console.error('upload parse error', err)
+          uni.showToast({ title: '头像上传失败', icon: 'none' })
+          reject(err)
+        }
+      },
+      fail: (err: any) => {
+        uni.hideLoading()
+        console.error('upload fail', err)
+        uni.showToast({ title: '头像上传失败', icon: 'none' })
+        reject(err)
+      },
+      complete: () => {
+        avatarUploading.value = false
+      }
+    })
+  })
+}
+
+/**
+ * 确保头像已上传到服务器;如果不是本域,则先下载再上传
+ */
+const ensureAvatarUploaded = async (): Promise<void> => {
+  if (!form.value.avatar) {
+    throw new Error('no avatar')
+  }
+  // 不再根据域名跳过上传:只要用户标记为编辑(avatarNeedsUpload),就上传
+
+  // 如果是远程 URL,需要先下载(包括本站域名的 URL)
+  if (isHttpUrl(form.value.avatar)) {
+    uni.showLoading({ title: '准备上传头像...' })
+    try {
+      const d = await uni.downloadFile({ url: form.value.avatar })
+      uni.hideLoading()
+      if (d.statusCode === 200 && d.tempFilePath) {
+        await uploadAvatar(d.tempFilePath)
+        avatarNeedsUpload.value = false
+        return
+      }
+      throw new Error('download failed')
+    } catch (err) {
+      uni.hideLoading()
+      throw err
+    }
+  }
+
+  // 否则可能是平台返回的本地临时路径,直接上传
+  await uploadAvatar(form.value.avatar)
+  avatarNeedsUpload.value = false
+}
+
+const onChooseAvatar = (e: any) => {
+  if (loading.value) {
+    console.log('onChooseAvatar ignored because loading')
+    return
+  }
   console.log('onChooseAvatar called with event:', e)
   try {
     const detail = e?.detail
@@ -166,9 +302,10 @@ const onChooseAvatar = (e: any) => {
     }
     if (url) {
       form.value.avatar = url
+      // 用户主动选择头像 -> 标记需要上传
       avatarEditedByUser.value = true
-      avatarNeedsUpload.value = true // 标记需要上传
-      console.log('Avatar URL updated to:', form.value.avatar)
+      avatarNeedsUpload.value = true
+      console.log('Avatar URL updated to:', form.value.avatar, 'needsUpload:', avatarNeedsUpload.value)
     }
     if (detail && detail.nickName && !form.value.nickname) {
       form.value.nickname = detail.nickName
@@ -182,82 +319,19 @@ const onChooseAvatar = (e: any) => {
   }
 }
 
-// 安全设置性别:在 loading 时禁止更改
-const setSex = (s: number) => {
-  if (loading.value) return
-  form.value.sex = s
-}
-
-const uploadAvatar = async (avatarUrl: string): Promise<string | null> => {
-  try {
-    avatarUploading.value = true
-    const token = uni.getStorageSync('token')
-    const uploadRes = await uni.uploadFile({
-      url: 'https://wx.baiyun.work/user/avatar/upload',
-      filePath: avatarUrl,
-      name: 'file',
-      header: {
-        Authorization: `Bearer ${token}`
-      }
-    })
-    if (uploadRes.statusCode === 200) {
-      const resp = JSON.parse(uploadRes.data)
-      if (resp && resp.code === 200 && resp.data) {
-        return resp.data // 返回上传后的头像 URL
-      }
-    }
-    throw new Error('Upload failed')
-  } catch (e) {
-    console.error('Upload avatar error:', e)
-    avatarUploading.value = false
-    return null
-  }
-}
-
-const ensureAvatarUploaded = async (): Promise<boolean> => {
-  if (!avatarNeedsUpload.value) return true
-  const avatarUrl = form.value.avatar
-  if (!avatarUrl) return false
-  // 不再根据 avatar 地址判断跳过上传:只要 avatarNeedsUpload 为 true,就执行上传流程
-  // 如果是远端 URL,先下载成临时文件再上传;若是本地临时文件则直接上传。
-  if (avatarUrl.startsWith('http')) {
-    try {
-      uni.showLoading({ title: '准备上传头像...' })
-      const d = await uni.downloadFile({ url: avatarUrl })
-      uni.hideLoading()
-      if (d.statusCode === 200 && d.tempFilePath) {
-        const uploadedUrl = await uploadAvatar(d.tempFilePath)
-        if (uploadedUrl) {
-          form.value.avatar = uploadedUrl
-          avatarNeedsUpload.value = false
-          return true
-        }
-      }
-    } catch (err) {
-      uni.hideLoading()
-      throw err
-    }
-    return false
-  }
-  // 否则,上传头像
-  const uploadedUrl = await uploadAvatar(avatarUrl)
-  if (uploadedUrl) {
-    form.value.avatar = uploadedUrl
-    avatarNeedsUpload.value = false
-    return true
-  }
-  return false
-}
-
 const startChooseAvatar = () => {
   console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
-  if (isChoosing.value) {
-    console.log('Already choosing, ignoring')
+  if (loading.value) {
+    console.log('Loading in progress, avatar choose ignored')
     return
   }
-  if (loading.value) return
+  // 用户点击头像组件表示想要更改头像:设置为需要上传(即使最终取消也将保留此标志)
   avatarEditedByUser.value = true
   avatarNeedsUpload.value = true
+  if (isChoosing.value) {
+    console.log('Already choosing, ignoring')
+    return
+  }
   isChoosing.value = true
   console.log('isChoosing set to true')
   setTimeout(() => {
@@ -266,6 +340,54 @@ const startChooseAvatar = () => {
   }, 3000)
 }
 
+
+
+const onRegionChange = (e: any) => {
+  region.value = e.detail.value
+  form.value.address = region.value.join(' ')
+}
+
+const getCurrentLocation = async () => {
+  if (gettingLocation.value) return
+  gettingLocation.value = true
+  try {
+    // 获取经纬度
+    const locationRes = await uni.getLocation({ type: 'wgs84' })
+    const { latitude, longitude } = locationRes
+    console.log('getLocation success:', locationRes)
+
+    // 调用用户自己的地理编码接口
+    const token = uni.getStorageSync('token')
+    const geocodeRes = await uni.request({
+      url: `https://wx.baiyun.work/geo/nearest?latitude=${latitude}&longitude=${longitude}`,
+      method: 'GET',
+      header: {
+        'Authorization': `Bearer ${token}`
+      }
+    })
+
+    const data = geocodeRes.data as any
+    if (data && data.code === 200 && data.data) {
+      // 解析 data 字段中的 JSON 字符串
+      const addressData = JSON.parse(data.data)
+      if (addressData && addressData.province && addressData.city) {
+        region.value = [addressData.province, addressData.city, addressData.district].filter(Boolean)
+        form.value.address = region.value.join(' ')
+        uni.showToast({ title: '位置获取成功', icon: 'success' })
+      } else {
+        throw new Error('Invalid address data')
+      }
+    } else {
+      throw new Error('Geocode failed')
+    }
+  } catch (err) {
+    console.error('Get location error:', err)
+    uni.showToast({ title: '获取位置失败,请检查定位权限和网络', icon: 'none' })
+  } finally {
+    gettingLocation.value = false
+  }
+}
+
 const onSubmit = async () => {
   if (submitting.value) return
 
@@ -284,6 +406,16 @@ const onSubmit = async () => {
     uni.showToast({ title: '姓名格式不正确,请输入2-10位中文或字母', icon: 'none' })
     return
   }
+  if (!form.value.phone) {
+    uni.showToast({ title: '请输入手机号', icon: 'none' })
+    return
+  }
+  // 手机号格式(中国)校验
+  const phoneRegex = /^1[3-9]\d{9}$/
+  if (!phoneRegex.test(String(form.value.phone))) {
+    uni.showToast({ title: '手机号格式不正确', icon: 'none' })
+    return
+  }
   if (!form.value.age) {
     uni.showToast({ title: '请输入年龄', icon: 'none' })
     return
@@ -298,19 +430,35 @@ const onSubmit = async () => {
     uni.showToast({ title: '请选择性别', icon: 'none' })
     return
   }
+  if (!form.value.address) {
+    uni.showToast({ title: '请选择或自动获取省/市', icon: 'none' })
+    return
+  }
 
-  submitting.value = true
+  // 如果头像还没有上传到服务端(非本域或本地临时文件),先上传
   try {
-    // 确保头像已上传
-    const avatarUploaded = await ensureAvatarUploaded()
-    if (!avatarUploaded) {
-      uni.showToast({ title: '头像上传失败', icon: 'none' })
+    if (avatarUploading.value) {
+      uni.showToast({ title: '头像上传中,请稍候', icon: 'none' })
       return
     }
+    // 只有当 avatarNeedsUpload 为 true,才会触发上传,避免重复上传
+    if (avatarNeedsUpload.value) {
+      await ensureAvatarUploaded()
+    }
+  } catch (err) {
+    console.error('Ensure avatar uploaded error:', err)
+    uni.showToast({ title: '头像上传失败,无法提交', icon: 'none' })
+    return
+  }
 
+  submitting.value = true
+  try {
     const token = uni.getStorageSync('token')
-    // 排除 avatar 字段,因为后端不允许在 update_user_info 中包含 avatar
-    const { avatar, ...updateData } = form.value
+    // 因为头像使用专属接口上传,后端不允许在 update_user_info 中包含 avatar 字段
+    // 所以这里构造 payload 时删除 avatar
+    const payload: any = JSON.parse(JSON.stringify(form.value))
+    if (payload.avatar !== undefined) delete payload.avatar
+
     const response = await uni.request({
       url: 'https://wx.baiyun.work/update_user_info',
       method: 'POST',
@@ -318,14 +466,14 @@ const onSubmit = async () => {
         'Content-Type': 'application/json',
         'Authorization': `Bearer ${token}`
       },
-      data: updateData
+      data: payload
     })
     console.log('Update response:', response)
     const resp = response.data as any
     if (resp && resp.code === 200) {
       uni.showToast({ title: '信息更新成功', icon: 'success' })
       setTimeout(() => {
-        uni.switchTab({ url: '/pages/doctor/profile/index' })
+        uni.switchTab({ url: '/pages/patient/profile/index' })
       }, 1500)
     } else {
       throw new Error('Update failed')
@@ -420,6 +568,16 @@ const onSubmit = async () => {
   font-size: 24rpx;
 }
 
+.avatar-uploading {
+  position: absolute;
+  bottom: 10rpx;
+  font-size: 22rpx;
+  color: #fff;
+  background: rgba(0,0,0,0.4);
+  padding: 6rpx 10rpx;
+  border-radius: 8rpx;
+}
+
 .form-item {
   margin-bottom: 30rpx;
 }
@@ -442,6 +600,25 @@ const onSubmit = async () => {
   max-width: 100%;
 }
 
+.phone-section {
+  display: flex;
+  align-items: center;
+}
+
+.get-phone-btn {
+  flex: 1;
+  height: 80rpx;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 32rpx;
+  border: none;
+}
+
+.get-phone-btn:disabled {
+  opacity: 0.5;
+}
+
 .radio-group {
   display: flex;
   gap: 40rpx;
@@ -454,6 +631,45 @@ const onSubmit = async () => {
   color: #333;
 }
 
+picker {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  display: flex;
+  align-items: center;
+  font-size: 32rpx;
+  color: hsl(0, 0%, 20%);
+  box-sizing: border-box;
+  max-width: 100%;
+}
+
+.form-item, .picker, .input, .get-location-btn, .submit-btn {
+  box-sizing: border-box;
+}
+
+.location-section {
+  display: flex;
+  align-items: center;
+  gap: 20rpx;
+}
+
+.get-location-btn {
+  flex-shrink: 0;
+  height: 80rpx;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 28rpx;
+  padding: 0 10rpx;
+  border: none;
+}
+
+.get-location-btn:disabled {
+  opacity: 0.5;
+}
+
 .submit-section {
   margin-top: 60rpx;
 }
@@ -471,4 +687,4 @@ const onSubmit = async () => {
 .submit-btn:disabled {
   opacity: 0.5;
 }
-</style>
+</style>

+ 109 - 45
src/pages/patient-family/profile/infos/base-info.vue

@@ -6,7 +6,7 @@
         <view class="avatar">
           <button
             class="avatar-wrapper"
-            :class="{ disabled: isChoosing || loading || avatarUploading }"
+            :class="{ disabled: isChoosing || avatarUploading || loading }"
             :disabled="loading || isChoosing || avatarUploading"
             open-type="chooseAvatar"
             @tap="startChooseAvatar"
@@ -15,6 +15,7 @@
             <view class="avatar-frame">
               <image v-if="form.avatar" class="avatar-img" :src="form.avatar" mode="aspectFill" />
               <text v-else class="avatar-placeholder">点击选择头像</text>
+              <text v-if="avatarUploading" class="avatar-uploading">上传中...</text>
             </view>
           </button>
         </view>
@@ -26,13 +27,13 @@
       </view>
 
       <view class="form-item">
-        <text class="label">年龄</text>
-        <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
+        <text class="label">手机号</text>
+        <input class="input" v-model="form.phone" :disabled="loading" placeholder="请输入手机号" type="number" />
       </view>
 
       <view class="form-item">
-        <text class="label">手机号</text>
-        <input class="input" v-model="form.phone" :disabled="loading" placeholder="请输入手机号" type="number" />
+        <text class="label">年龄</text>
+        <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
       </view>
 
       <view class="form-item">
@@ -61,8 +62,10 @@
         </view>
       </view>
 
+
+
       <view class="submit-section">
-        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : '提交' }}</button>
+        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : avatarUploading ? '头像上传中...' : '提交' }}</button>
       </view>
     </view>
   </view>
@@ -74,8 +77,6 @@ const loading = ref(false)
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 
-const isHttpUrl = (url: string) => /^https?:\/\//i.test(url)
-
 const form = ref({
   avatar: '',
   nickname: '',
@@ -88,18 +89,19 @@ const form = ref({
 const region = ref<string[]>([])
 const submitting = ref(false)
 const isChoosing = ref(false)
-const gettingLocation = ref(false)
+const avatarUploading = ref(false)
 const avatarNeedsUpload = ref(false)
+// 标记用户是否主动选择/更改头像(用来控制是否上传)
 const avatarEditedByUser = ref(false)
-const avatarUploading = ref(false)
+const gettingLocation = ref(false)
 
 // 从后端拉取已有用户信息并填充表单
 const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
-    // 不要在 fetchUserInfo 中上传头像(避免启动时副作用)
     if (!token) return
     loading.value = true
+    // 使用 mask: true 阻止用户在加载/下载时继续交互
     uni.showLoading({ title: '加载中...', mask: true })
     const response = await uni.request({
       url: 'https://wx.baiyun.work/user_info',
@@ -110,7 +112,7 @@ const fetchUserInfo = async () => {
       },
       data: {}
     })
-    uni.hideLoading()
+    
     console.log('fetchUserInfo response:', response)
     if (response.statusCode === 401) {
       // 未授权,跳转登录
@@ -122,7 +124,7 @@ const fetchUserInfo = async () => {
     if (resp && resp.code === 200 && resp.data) {
       const d = resp.data
       // 不再判断 avatar 地址:默认从服务端下载头像(如果有 userId)
-      // 统一展示为本地临时路径
+      // 这样即使后端返回 URL,也会下载到本地临时路径以便统一展示
       {
         const userId = d.id || d.userId
         if (userId) {
@@ -142,9 +144,10 @@ const fetchUserInfo = async () => {
         }
       }
       // 填充基本字段
-      form.value.avatar = d.avatar || form.value.avatar
-      avatarNeedsUpload.value = false
-      avatarEditedByUser.value = false
+        form.value.avatar = d.avatar || form.value.avatar
+        // 完成从服务器获取资料后默认为不上传,等待用户主动修改头像
+        avatarNeedsUpload.value = false
+        avatarEditedByUser.value = false
       form.value.nickname = d.nickname || form.value.nickname
       form.value.phone = d.phone || form.value.phone
       form.value.age = d.age || form.value.age
@@ -164,7 +167,7 @@ const fetchUserInfo = async () => {
         form.value.address = parts.join(' ')
       }
     }
-  } catch (err) {
+    } catch (err) {
     console.error('fetchUserInfo error:', err)
   } finally {
     uni.hideLoading()
@@ -177,6 +180,9 @@ onShow(() => {
   fetchUserInfo()
 })
 
+
+const isHttpUrl = (url: string) => /^https?:\/\//i.test(url)
+
 // 安全设置性别:在 loading 时禁止更改
 const setSex = (s: number) => {
   if (loading.value) return
@@ -199,14 +205,20 @@ const uploadAvatar = (filePath: string) => {
       url: 'https://wx.baiyun.work/user/avatar/upload',
       filePath,
       name: 'file',
-      header: { Authorization: `Bearer ${token}` },
+      header: {
+        Authorization: `Bearer ${token}`
+      },
       success: (uploadRes: any) => {
         uni.hideLoading()
         try {
           const resp = JSON.parse(uploadRes.data || '{}') as any
           if (resp && resp.code === 200) {
+            // 尝试从响应中取出 URL 字段(后端可能返回 data.url 或 data.path 等)
             const uploadedUrl = resp.data?.url || resp.data?.path || resp.data?.fileUrl || ''
-            if (uploadedUrl) form.value.avatar = uploadedUrl
+            if (uploadedUrl) {
+              form.value.avatar = uploadedUrl
+            }
+            // 标记为已上传
             avatarNeedsUpload.value = false
             uni.showToast({ title: '头像上传成功', icon: 'success' })
             resolve(uploadedUrl || '')
@@ -233,8 +245,16 @@ const uploadAvatar = (filePath: string) => {
   })
 }
 
+/**
+ * 确保头像已上传到服务器;如果不是本域,则先下载再上传
+ */
 const ensureAvatarUploaded = async (): Promise<void> => {
-  if (!form.value.avatar) throw new Error('no avatar')
+  if (!form.value.avatar) {
+    throw new Error('no avatar')
+  }
+  // 不再根据域名跳过上传:只要用户标记为编辑(avatarNeedsUpload),就上传
+
+  // 如果是远程 URL,需要先下载(包括本站域名的 URL)
   if (isHttpUrl(form.value.avatar)) {
     uni.showLoading({ title: '准备上传头像...' })
     try {
@@ -251,12 +271,17 @@ const ensureAvatarUploaded = async (): Promise<void> => {
       throw err
     }
   }
+
+  // 否则可能是平台返回的本地临时路径,直接上传
   await uploadAvatar(form.value.avatar)
   avatarNeedsUpload.value = false
 }
 
 const onChooseAvatar = (e: any) => {
-  if (loading.value) return
+  if (loading.value) {
+    console.log('onChooseAvatar ignored because loading')
+    return
+  }
   console.log('onChooseAvatar called with event:', e)
   try {
     const detail = e?.detail
@@ -277,9 +302,10 @@ const onChooseAvatar = (e: any) => {
     }
     if (url) {
       form.value.avatar = url
+      // 用户主动选择头像 -> 标记需要上传
       avatarEditedByUser.value = true
       avatarNeedsUpload.value = true
-      console.log('Avatar URL updated to:', form.value.avatar)
+      console.log('Avatar URL updated to:', form.value.avatar, 'needsUpload:', avatarNeedsUpload.value)
     }
     if (detail && detail.nickName && !form.value.nickname) {
       form.value.nickname = detail.nickName
@@ -295,13 +321,17 @@ const onChooseAvatar = (e: any) => {
 
 const startChooseAvatar = () => {
   console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
-  if (isChoosing.value) {
-    console.log('Already choosing, ignoring')
+  if (loading.value) {
+    console.log('Loading in progress, avatar choose ignored')
     return
   }
-  if (loading.value) return
+  // 用户点击头像组件表示想要更改头像:设置为需要上传(即使最终取消也将保留此标志)
   avatarEditedByUser.value = true
   avatarNeedsUpload.value = true
+  if (isChoosing.value) {
+    console.log('Already choosing, ignoring')
+    return
+  }
   isChoosing.value = true
   console.log('isChoosing set to true')
   setTimeout(() => {
@@ -310,6 +340,8 @@ const startChooseAvatar = () => {
   }, 3000)
 }
 
+
+
 const onRegionChange = (e: any) => {
   region.value = e.detail.value
   form.value.address = region.value.join(' ')
@@ -374,16 +406,6 @@ const onSubmit = async () => {
     uni.showToast({ title: '姓名格式不正确,请输入2-10位中文或字母', icon: 'none' })
     return
   }
-  if (!form.value.age) {
-    uni.showToast({ title: '请输入年龄', icon: 'none' })
-    return
-  }
-  // 年龄为 1-120 的整数
-  const ageNum = Number(form.value.age)
-  if (!Number.isInteger(ageNum) || ageNum < 1 || ageNum > 120) {
-    uni.showToast({ title: '年龄请输入1到120之间的整数', icon: 'none' })
-    return
-  }
   if (!form.value.phone) {
     uni.showToast({ title: '请输入手机号', icon: 'none' })
     return
@@ -394,6 +416,16 @@ const onSubmit = async () => {
     uni.showToast({ title: '手机号格式不正确', icon: 'none' })
     return
   }
+  if (!form.value.age) {
+    uni.showToast({ title: '请输入年龄', icon: 'none' })
+    return
+  }
+  // 年龄为 1-120 的整数
+  const ageNum = Number(form.value.age)
+  if (!Number.isInteger(ageNum) || ageNum < 1 || ageNum > 120) {
+    uni.showToast({ title: '年龄请输入1到120之间的整数', icon: 'none' })
+    return
+  }
   if (!form.value.sex) {
     uni.showToast({ title: '请选择性别', icon: 'none' })
     return
@@ -403,27 +435,30 @@ const onSubmit = async () => {
     return
   }
 
-  submitting.value = true
+  // 如果头像还没有上传到服务端(非本域或本地临时文件),先上传
   try {
     if (avatarUploading.value) {
       uni.showToast({ title: '头像上传中,请稍候', icon: 'none' })
       return
     }
-    // 如果用户点击头像导致 avatarNeedsUpload 为 true,则先上传
+    // 只有当 avatarNeedsUpload 为 true,才会触发上传,避免重复上传
     if (avatarNeedsUpload.value) {
-      try {
-        await ensureAvatarUploaded()
-      } catch (err) {
-        console.error('Ensure avatar uploaded error:', err)
-        uni.showToast({ title: '头像上传失败,无法提交', icon: 'none' })
-        return
-      }
+      await ensureAvatarUploaded()
     }
+  } catch (err) {
+    console.error('Ensure avatar uploaded error:', err)
+    uni.showToast({ title: '头像上传失败,无法提交', icon: 'none' })
+    return
+  }
+
+  submitting.value = true
+  try {
     const token = uni.getStorageSync('token')
     // 因为头像使用专属接口上传,后端不允许在 update_user_info 中包含 avatar 字段
     // 所以这里构造 payload 时删除 avatar
     const payload: any = JSON.parse(JSON.stringify(form.value))
     if (payload.avatar !== undefined) delete payload.avatar
+
     const response = await uni.request({
       url: 'https://wx.baiyun.work/update_user_info',
       method: 'POST',
@@ -438,7 +473,7 @@ const onSubmit = async () => {
     if (resp && resp.code === 200) {
       uni.showToast({ title: '信息更新成功', icon: 'success' })
       setTimeout(() => {
-        uni.switchTab({ url: '/pages/patient-family/profile/index' })
+        uni.switchTab({ url: '/pages/patient/profile/index' })
       }, 1500)
     } else {
       throw new Error('Update failed')
@@ -533,6 +568,16 @@ const onSubmit = async () => {
   font-size: 24rpx;
 }
 
+.avatar-uploading {
+  position: absolute;
+  bottom: 10rpx;
+  font-size: 22rpx;
+  color: #fff;
+  background: rgba(0,0,0,0.4);
+  padding: 6rpx 10rpx;
+  border-radius: 8rpx;
+}
+
 .form-item {
   margin-bottom: 30rpx;
 }
@@ -555,6 +600,25 @@ const onSubmit = async () => {
   max-width: 100%;
 }
 
+.phone-section {
+  display: flex;
+  align-items: center;
+}
+
+.get-phone-btn {
+  flex: 1;
+  height: 80rpx;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 32rpx;
+  border: none;
+}
+
+.get-phone-btn:disabled {
+  opacity: 0.5;
+}
+
 .radio-group {
   display: flex;
   gap: 40rpx;