فهرست منبع

feat(patient-profile): 完善头像上传与交互逻辑

- 默认从服务端下载用户头像,不再判断URL域名
- 页面加载期间禁用所有交互并显示加载遮罩
- 用户点击头像即标记为需要上传,无论是否更改
- 新增头像上传状态控制与防重复提交机制
- 统一处理医生、患者、家属端头像逻辑
- 移除 update_user_info 接口中的 avatar 字段
- 增加上传失败提示与用户体验优化
mcbaiyun 1 ماه پیش
والد
کامیت
089ec3aae5

+ 129 - 0
docs/base-info-avatar-logic.md

@@ -0,0 +1,129 @@
+# 完善基本信息页面(`base-info.vue`)头像处理与交互逻辑
+
+## 概述 ✅
+本文件记录 `src/pages/patient/profile/infos/base-info.vue` 中关于头像(avatar)下载、选择与上传的实现细节,并**特别强调**你要求的行为:
+
+- 在页面加载时默认从服务端下载用户头像(不再判断 avatar 地址是否包含特定域名)。
+- 在页面从服务器获取并填充资料期间,禁止用户交互(整体锁定)。
+- 只有当用户主动点击头像控件尝试更改时,才将“需要上传头像”标记设为 true —— 即“只要用户尝试更改就上传”,不要做任何域名判断。
+
+这些变更已在 `base-info.vue` 中实现,并在此文档中逐条解释。⚠️ 强调:文档中所有代码符号均以 `code` 标记引用。
+
+---
+
+## 关键响应式变量(状态)🔧
+- `form`:包含 `avatar`, `nickname`, `phone`, `age`, `sex`, `address`。
+- `region`:`picker` 的地区数组。
+- `loading`:当从服务器拉取资料/下载头像时为 true。用于禁用交互并配合 `uni.showLoading({ mask: true })` 阻止触摸。见 `fetchUserInfo()`。
+- `isChoosing`:头像选择防抖/状态。
+- `avatarUploading`:头像上传中标志。
+- `avatarNeedsUpload`:当为 `true` 时,`onSubmit()` 提交前会触发上传。默认在 `fetchUserInfo()` 结束时设为 `false`。
+- `avatarEditedByUser`:表示用户是否主动点击了头像控件(用来控制上传语义)。
+
+---
+
+## 页面加载与填充逻辑(`fetchUserInfo()`)📥
+1. 页面显示时触发 `onShow` → `fetchUserInfo()`。
+2. `fetchUserInfo()` 做以下事:
+   - 读取 token(`uni.getStorageSync('token')`),若无直接返回。
+   - set `loading = true` 并 `uni.showLoading({ title: '加载中...', mask: true })`,阻止与页面交互。
+   - 发起 `POST https://wx.baiyun.work/user_info`,解析并填入 `form` 字段。
+   - 不管 `d.avatar` 是何种 URL,若 `userId` 存在都会调用:
+     ```ts
+     uni.downloadFile({ url: `https://wx.baiyun.work/user/avatar/${userId}` })
+     ```
+     若下载成功,`d.avatar = downloadRes.tempFilePath`。
+   - 填充:`form.value.avatar = d.avatar || form.value.avatar`。
+   - 默认:
+     ```ts
+     avatarNeedsUpload.value = false
+     avatarEditedByUser.value = false
+     ```
+     即:完成从服务器获取资料后默认不上传,等用户主动更改。  
+   - finally 中 `uni.hideLoading()` 和 `loading = false`,恢复交互。 
+
+> 重要:这里的实现逻辑**不会**对 `d.avatar` 的 URL 做域名判断或条件分支;总是尝试下载(只要有 userId)。
+
+---
+
+## 用户交互:点击与选择头像(`startChooseAvatar` / `onChooseAvatar`)🖱️
+- 用户点击头像:
+  - `startChooseAvatar()` 会先检查 `loading`,若正在下载则忽略点击;否则:
+    - 将 `avatarEditedByUser.value = true` 与 `avatarNeedsUpload.value = true`(即只要用户点击尝试更改就上传)。
+    - 进入选择流程(防重入控制 `isChoosing`)。
+- 用户选择成功:`onChooseAvatar(e)` 会设置 `form.value.avatar = url`,并再次将 `avatarEditedByUser` 与 `avatarNeedsUpload` 设为 `true`。
+- 如果用户取消选择:依然保留 `avatarEditedByUser=true` 与 `avatarNeedsUpload=true`(这是你明确要求的:「只要点击就上传」)。
+
+> 注:如果你希望“点击但取消后不上传”,可以把 `avatarNeedsUpload` 的设置移到 `onChooseAvatar` 成功时再设,而不是 `startChooseAvatar`。但目前实现为“只要点击就上传”。
+
+---
+
+## 上传流程(`ensureAvatarUploaded()` 与 `uploadAvatar()`)⬆️
+- `onSubmit()` 检查 `avatarNeedsUpload`;如果 `true` 且 `avatarUploading` 为 false,会调用 `await ensureAvatarUploaded()`。
+- 旧逻辑会绕过本站域名,但现在已移除域名判断(按你的要求):无论 `form.avatar` 是哪种 URL,只要 `avatarNeedsUpload === true`,执行下载再上传流程:
+  1. 如果 `form.avatar` 是远程 URL(`isHttpUrl` 为 true),先 `uni.downloadFile({ url: form.value.avatar })`,得到 tempFilePath 后 `uploadAvatar(tempFilePath)`。
+  2. 如果 `form.avatar` 已经是本地临时路径(如拍照/本地选择),直接调用 `uploadAvatar(form.value.avatar)`。
+- `uploadAvatar()` 使用 `uni.uploadFile`,解析后端返回的 `data.url` / `data.path` / `data.fileUrl`,如果返回会把 `form.value.avatar` 更新为服务器的新 URL;若上传成功会将 `avatarNeedsUpload.value = false`。
+
+---
+
+## 禁止页面交互(实现)🚫
+- 页面加载并下载服务器资料期间,`loading = true`。
+- 在模板中把不同输入/按钮的 `:disabled` 与 `loading` 关联:例如:
+  ```html
+  <input :disabled="loading" ... />
+  <button :disabled="loading || isChoosing || avatarUploading" ... />
+  <picker :disabled="loading" ... />
+  ```
+- 同时使用 `uni.showLoading({ mask:true })` 让小程序层面遮罩并拦截触摸操作,避免 race 条件。
+
+---
+
+## 如何映射到你的要求(对照)🎯
+- 你要:「不判断头像地址,默认就下载头像」
+  - 实现:在 `fetchUserInfo()` 中只要 `userId` 存在就 `uni.downloadFile` 下载头像(不做 `http`/域名判断)。
+- 你要:「只要用户点击头像组件尝试更改头像就上传头像」
+  - 实现:`startChooseAvatar()` 中设置 `avatarNeedsUpload = true`(所以只要用户点了头像就会在提交时上传)。
+- 你要:「在下载/填充阶段禁止用户操作页面」
+  - 实现:使用 `loading` + `uni.showLoading({ mask:true })`,并在 template 将 `disabled` 关联到 `loading`。
+
+---
+
+## 行为与测试(建议用例)🧪
+- 浏览器/真机:打开页面,验证 `loading` 时界面不可操作,头像能从 `user/avatar/${userId}` 下载并展示。
+- 点击头像控件(但不选择),提交应在 `onSubmit` 触发时进行头像 upload(因为点击就 set `avatarNeedsUpload=true`)。
+- 点击头像控件并选择图片,提交应上传新图片并提交表单。
+- 若 `ensureAvatarUploaded()` 下载或上传失败,应在 `catch` 里提示并保持页面可用(当前实现就是此行为)。
+
+---
+
+## 代码定位(引用)📎
+- 页面实现:`src/pages/patient/profile/infos/base-info.vue`。
+- 主要 symbol:
+  - `loading`, `avatarNeedsUpload`, `avatarEditedByUser`。
+  - `fetchUserInfo()`、`startChooseAvatar()`、`onChooseAvatar()`、`ensureAvatarUploaded()`、`uploadAvatar()`。
+
+---
+
+## 可选优化(未来)🔧
+- 如果你想要更精细的行为(比如点击但取消不上传)我可以把 `avatarNeedsUpload` 的设置移动到 `onChooseAvatar` 成功分支。当前实现严格按照你的要求“点击即上传”。
+- 把头像逻辑抽象到 composable(`composables/useAvatar.ts`),其他 `base-info.vue` 页面(`doctor`、`patient-family` 等)也可以复用相同策略。
+- 增加 UI 提示:当 `avatarEditedByUser` 为 true 时,在界面上显示提示信息 "你已更改头像,提交后会上传并替换服务器头像"。
+
+---
+
+文档到此结束!如需我把这份文档复制并同步到其它 `base-info.vue`(doctor / patient-family)或把 avatar 抽象为 `composable` 可复用模块,我可以继续实现。
+
+---
+
+## 已同步到的页面(本次改动) ✅
+- `src/pages/patient/profile/infos/base-info.vue` (原始实现)
+- `src/pages/doctor/profile/infos/base-info.vue`(已同步:相同的 avatar 下载、禁止交互、点击即上传 逻辑)
+- `src/pages/patient-family/profile/infos/base-info.vue`(已同步:相同的 avatar 下载、禁止交互、点击即上传 逻辑)
+
+另外,已经同步 `update_user_info` payload 行为(删除 avatar 字段)到:
+- `src/pages/doctor/profile/infos/base-info.vue`
+- `src/pages/patient-family/profile/infos/base-info.vue`
+这样后端不会在 `update_user_info` 接口中接收 avatar 字段,头像上传仍通过专门接口完成。
+
+这些页面的行为已被统一,详细实现请查看每个页面的 `fetchUserInfo()`、`startChooseAvatar()`、`onChooseAvatar()`、`ensureAvatarUploaded()` 的代码注释。

+ 19 - 0
src/pages/doctor/index/index.vue

@@ -178,6 +178,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       uni.setStorageSync('user_info', resp.data)
       if (!resp.data.nickname || !resp.data.avatar) {

+ 19 - 0
src/pages/doctor/profile/index.vue

@@ -108,6 +108,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       // 保存到本地存储
       uni.setStorageSync('user_info', resp.data)

+ 119 - 10
src/pages/doctor/profile/infos/base-info.vue

@@ -6,8 +6,8 @@
         <view class="avatar">
           <button
             class="avatar-wrapper"
-            :class="{ disabled: isChoosing }"
-            :disabled="isChoosing"
+            :class="{ disabled: isChoosing || avatarUploading || loading }"
+            :disabled="loading || isChoosing || avatarUploading"
             open-type="chooseAvatar"
             @tap="startChooseAvatar"
             @chooseavatar="onChooseAvatar"
@@ -22,22 +22,22 @@
 
       <view class="form-item">
         <text class="label">姓名</text>
-        <input class="input" type="nickname" v-model="form.nickname" placeholder="请输入姓名" />
+        <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.age" placeholder="请输入年龄" type="number" />
+        <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
       </view>
 
       <view class="form-item">
         <text class="label">性别</text>
         <view class="radio-group">
-          <label class="radio-item" @click="form.sex = 1">
+          <label class="radio-item" @click="setSex(1)">
             <radio :checked="form.sex === 1" color="#07C160" />
             <text>男</text>
           </label>
-          <label class="radio-item" @click="form.sex = 2">
+          <label class="radio-item" @click="setSex(2)">
             <radio :checked="form.sex === 2" color="#07C160" />
             <text>女</text>
           </label>
@@ -45,7 +45,7 @@
       </view>
 
       <view class="submit-section">
-        <button class="submit-btn" @click="onSubmit" :disabled="submitting">{{ submitting ? '提交中...' : '提交' }}</button>
+        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : '提交' }}</button>
       </view>
     </view>
   </view>
@@ -53,6 +53,8 @@
 
 <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'
 
@@ -65,13 +67,17 @@ const form = ref({
 
 const submitting = ref(false)
 const isChoosing = ref(false)
+const avatarNeedsUpload = ref(false)
+const avatarUploading = ref(false)
+const avatarEditedByUser = ref(false)
 
 // 从后端拉取已有用户信息并填充表单
 const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
     if (!token) return
-    uni.showLoading({ title: '加载中...' })
+    loading.value = true
+    uni.showLoading({ title: '加载中...', mask: true })
     const response = await uni.request({
       url: 'https://wx.baiyun.work/user_info',
       method: 'POST',
@@ -92,6 +98,24 @@ const fetchUserInfo = async () => {
     const resp = response.data as any
     if (resp && resp.code === 200 && resp.data) {
       const d = resp.data
+      // 不再判断 avatar 地址:默认从服务端下载头像(如果有 userId)
+      // 这样即使后端返回 URL,也会下载到本地临时路径以便统一展示
+      {
+        const userId = d.id || d.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: { Authorization: `Bearer ${token}` }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              d.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       // 填充基本字段
       form.value.avatar = d.avatar || form.value.avatar
       form.value.nickname = d.nickname || form.value.nickname
@@ -107,8 +131,11 @@ const fetchUserInfo = async () => {
       }
     }
   } catch (err) {
-    uni.hideLoading()
     console.error('fetchUserInfo error:', err)
+  } finally {
+    uni.hideLoading()
+    loading.value = false
+  
   }
 }
 
@@ -118,6 +145,7 @@ onShow(() => {
 })
 
 const onChooseAvatar = (e: any) => {
+  if (loading.value) return
   console.log('onChooseAvatar called with event:', e)
   try {
     const detail = e?.detail
@@ -138,6 +166,8 @@ 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)
     }
     if (detail && detail.nickName && !form.value.nickname) {
@@ -152,12 +182,82 @@ 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')
     return
   }
+  if (loading.value) return
+  avatarEditedByUser.value = true
+  avatarNeedsUpload.value = true
   isChoosing.value = true
   console.log('isChoosing set to true')
   setTimeout(() => {
@@ -201,7 +301,16 @@ const onSubmit = async () => {
 
   submitting.value = true
   try {
+    // 确保头像已上传
+    const avatarUploaded = await ensureAvatarUploaded()
+    if (!avatarUploaded) {
+      uni.showToast({ title: '头像上传失败', icon: 'none' })
+      return
+    }
+
     const token = uni.getStorageSync('token')
+    // 排除 avatar 字段,因为后端不允许在 update_user_info 中包含 avatar
+    const { avatar, ...updateData } = form.value
     const response = await uni.request({
       url: 'https://wx.baiyun.work/update_user_info',
       method: 'POST',
@@ -209,7 +318,7 @@ const onSubmit = async () => {
         'Content-Type': 'application/json',
         'Authorization': `Bearer ${token}`
       },
-      data: form.value
+      data: updateData
     })
     console.log('Update response:', response)
     const resp = response.data as any

+ 19 - 0
src/pages/patient-family/index/index.vue

@@ -198,6 +198,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       uni.setStorageSync('user_info', resp.data)
       if (!resp.data.nickname || !resp.data.avatar) {

+ 19 - 0
src/pages/patient-family/profile/index.vue

@@ -108,6 +108,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       // 保存到本地存储
       uni.setStorageSync('user_info', resp.data)

+ 147 - 13
src/pages/patient-family/profile/infos/base-info.vue

@@ -6,8 +6,8 @@
         <view class="avatar">
           <button
             class="avatar-wrapper"
-            :class="{ disabled: isChoosing }"
-            :disabled="isChoosing"
+            :class="{ disabled: isChoosing || loading || avatarUploading }"
+            :disabled="loading || isChoosing || avatarUploading"
             open-type="chooseAvatar"
             @tap="startChooseAvatar"
             @chooseavatar="onChooseAvatar"
@@ -22,27 +22,27 @@
 
       <view class="form-item">
         <text class="label">姓名</text>
-        <input class="input" type="nickname" v-model="form.nickname" placeholder="请输入姓名" />
+        <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.age" placeholder="请输入年龄" type="number" />
+        <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
       </view>
 
       <view class="form-item">
         <text class="label">手机号</text>
-        <input class="input" v-model="form.phone" placeholder="请输入手机号" type="number" />
+        <input class="input" v-model="form.phone" :disabled="loading" placeholder="请输入手机号" type="number" />
       </view>
 
       <view class="form-item">
         <text class="label">性别</text>
         <view class="radio-group">
-          <label class="radio-item" @click="form.sex = 1">
+          <label class="radio-item" @click="setSex(1)">
             <radio :checked="form.sex === 1" color="#07C160" />
             <text>男</text>
           </label>
-          <label class="radio-item" @click="form.sex = 2">
+          <label class="radio-item" @click="setSex(2)">
             <radio :checked="form.sex === 2" color="#07C160" />
             <text>女</text>
           </label>
@@ -52,17 +52,17 @@
       <view class="form-item">
         <text class="label">省/市</text>
         <view class="location-section">
-          <picker mode="region" :value="region" @change="onRegionChange">
+          <picker mode="region" :value="region" :disabled="loading" @change="onRegionChange">
             <view class="picker">{{ region.join(' ') || '请选择省/市' }}</view>
           </picker>
-          <button class="get-location-btn" @click="getCurrentLocation" :disabled="gettingLocation">
+          <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="submitting">{{ submitting ? '提交中...' : '提交' }}</button>
+        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : '提交' }}</button>
       </view>
     </view>
   </view>
@@ -70,9 +70,12 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
+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: '',
@@ -86,13 +89,18 @@ const region = ref<string[]>([])
 const submitting = ref(false)
 const isChoosing = ref(false)
 const gettingLocation = ref(false)
+const avatarNeedsUpload = ref(false)
+const avatarEditedByUser = ref(false)
+const avatarUploading = ref(false)
 
 // 从后端拉取已有用户信息并填充表单
 const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
+    // 不要在 fetchUserInfo 中上传头像(避免启动时副作用)
     if (!token) return
-    uni.showLoading({ title: '加载中...' })
+    loading.value = true
+    uni.showLoading({ title: '加载中...', mask: true })
     const response = await uni.request({
       url: 'https://wx.baiyun.work/user_info',
       method: 'POST',
@@ -113,8 +121,30 @@ const fetchUserInfo = async () => {
     const resp = response.data as any
     if (resp && resp.code === 200 && resp.data) {
       const d = resp.data
+      // 不再判断 avatar 地址:默认从服务端下载头像(如果有 userId)
+      // 统一展示为本地临时路径
+      {
+        const userId = d.id || d.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              d.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       // 填充基本字段
       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
@@ -135,8 +165,10 @@ const fetchUserInfo = async () => {
       }
     }
   } catch (err) {
-    uni.hideLoading()
     console.error('fetchUserInfo error:', err)
+  } finally {
+    uni.hideLoading()
+    loading.value = false
   }
 }
 
@@ -145,7 +177,86 @@ onShow(() => {
   fetchUserInfo()
 })
 
+// 安全设置性别:在 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) {
+            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')
+  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) return
   console.log('onChooseAvatar called with event:', e)
   try {
     const detail = e?.detail
@@ -166,6 +277,8 @@ 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)
     }
     if (detail && detail.nickName && !form.value.nickname) {
@@ -186,6 +299,9 @@ const startChooseAvatar = () => {
     console.log('Already choosing, ignoring')
     return
   }
+  if (loading.value) return
+  avatarEditedByUser.value = true
+  avatarNeedsUpload.value = true
   isChoosing.value = true
   console.log('isChoosing set to true')
   setTimeout(() => {
@@ -289,7 +405,25 @@ const onSubmit = async () => {
 
   submitting.value = true
   try {
+    if (avatarUploading.value) {
+      uni.showToast({ title: '头像上传中,请稍候', icon: 'none' })
+      return
+    }
+    // 如果用户点击头像导致 avatarNeedsUpload 为 true,则先上传
+    if (avatarNeedsUpload.value) {
+      try {
+        await ensureAvatarUploaded()
+      } catch (err) {
+        console.error('Ensure avatar uploaded error:', err)
+        uni.showToast({ title: '头像上传失败,无法提交', icon: 'none' })
+        return
+      }
+    }
     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',
@@ -297,7 +431,7 @@ const onSubmit = async () => {
         'Content-Type': 'application/json',
         'Authorization': `Bearer ${token}`
       },
-      data: form.value
+      data: payload
     })
     console.log('Update response:', response)
     const resp = response.data as any

+ 19 - 0
src/pages/patient/index/index.vue

@@ -174,6 +174,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       uni.setStorageSync('user_info', resp.data)
       if (!resp.data.nickname || !resp.data.avatar) {

+ 19 - 0
src/pages/patient/profile/index.vue

@@ -111,6 +111,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       // 保存到本地存储
       uni.setStorageSync('user_info', resp.data)

+ 191 - 17
src/pages/patient/profile/infos/base-info.vue

@@ -6,8 +6,8 @@
         <view class="avatar">
           <button
             class="avatar-wrapper"
-            :class="{ disabled: isChoosing }"
-            :disabled="isChoosing"
+            :class="{ disabled: isChoosing || avatarUploading || loading }"
+            :disabled="loading || isChoosing || avatarUploading"
             open-type="chooseAvatar"
             @tap="startChooseAvatar"
             @chooseavatar="onChooseAvatar"
@@ -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>
@@ -22,27 +23,27 @@
 
       <view class="form-item">
         <text class="label">姓名</text>
-        <input class="input" type="nickname" v-model="form.nickname" placeholder="请输入姓名" />
+        <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" placeholder="请输入手机号" type="number" />
+        <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" placeholder="请输入年龄" type="number" />
+        <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
       </view>
 
       <view class="form-item">
         <text class="label">性别</text>
         <view class="radio-group">
-          <label class="radio-item" @click="form.sex = 1">
+          <label class="radio-item" @click="setSex(1)">
             <radio :checked="form.sex === 1" color="#07C160" />
             <text>男</text>
           </label>
-          <label class="radio-item" @click="form.sex = 2">
+          <label class="radio-item" @click="setSex(2)">
             <radio :checked="form.sex === 2" color="#07C160" />
             <text>女</text>
           </label>
@@ -52,10 +53,10 @@
       <view class="form-item">
         <text class="label">省/市</text>
         <view class="location-section">
-          <picker mode="region" :value="region" @change="onRegionChange">
+          <picker mode="region" :value="region" :disabled="loading" @change="onRegionChange">
             <view class="picker">{{ region.join(' ') || '请选择省/市' }}</view>
           </picker>
-          <button class="get-location-btn" @click="getCurrentLocation" :disabled="gettingLocation">
+          <button class="get-location-btn" @click="getCurrentLocation" :disabled="loading || gettingLocation">
             {{ gettingLocation ? '获取中...' : '使用当前定位' }}
           </button>
         </view>
@@ -64,7 +65,7 @@
 
 
       <view class="submit-section">
-        <button class="submit-btn" @click="onSubmit" :disabled="submitting">{{ submitting ? '提交中...' : '提交' }}</button>
+        <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : avatarUploading ? '头像上传中...' : '提交' }}</button>
       </view>
     </view>
   </view>
@@ -72,6 +73,7 @@
 
 <script setup lang="ts">
 import { ref } from 'vue'
+const loading = ref(false)
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 
@@ -87,6 +89,10 @@ const form = ref({
 const region = ref<string[]>([])
 const submitting = ref(false)
 const isChoosing = ref(false)
+const avatarUploading = ref(false)
+const avatarNeedsUpload = ref(false)
+// 标记用户是否主动选择/更改头像(用来控制是否上传)
+const avatarEditedByUser = ref(false)
 const gettingLocation = ref(false)
 
 // 从后端拉取已有用户信息并填充表单
@@ -94,7 +100,9 @@ const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
     if (!token) return
-    uni.showLoading({ title: '加载中...' })
+    loading.value = true
+    // 使用 mask: true 阻止用户在加载/下载时继续交互
+    uni.showLoading({ title: '加载中...', mask: true })
     const response = await uni.request({
       url: 'https://wx.baiyun.work/user_info',
       method: 'POST',
@@ -104,7 +112,7 @@ const fetchUserInfo = async () => {
       },
       data: {}
     })
-    uni.hideLoading()
+    
     console.log('fetchUserInfo response:', response)
     if (response.statusCode === 401) {
       // 未授权,跳转登录
@@ -115,8 +123,31 @@ const fetchUserInfo = async () => {
     const resp = response.data as any
     if (resp && resp.code === 200 && resp.data) {
       const d = resp.data
+      // 不再判断 avatar 地址:默认从服务端下载头像(如果有 userId)
+      // 这样即使后端返回 URL,也会下载到本地临时路径以便统一展示
+      {
+        const userId = d.id || d.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              d.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       // 填充基本字段
-      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
@@ -136,9 +167,11 @@ const fetchUserInfo = async () => {
         form.value.address = parts.join(' ')
       }
     }
-  } catch (err) {
-    uni.hideLoading()
+    } catch (err) {
     console.error('fetchUserInfo error:', err)
+  } finally {
+    uni.hideLoading()
+    loading.value = false
   }
 }
 
@@ -148,7 +181,107 @@ onShow(() => {
 })
 
 
+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
@@ -169,7 +302,10 @@ const onChooseAvatar = (e: any) => {
     }
     if (url) {
       form.value.avatar = url
-      console.log('Avatar URL updated to:', form.value.avatar)
+      // 用户主动选择头像 -> 标记需要上传
+      avatarEditedByUser.value = true
+      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
@@ -185,6 +321,13 @@ const onChooseAvatar = (e: any) => {
 
 const startChooseAvatar = () => {
   console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
+  if (loading.value) {
+    console.log('Loading in progress, avatar choose ignored')
+    return
+  }
+  // 用户点击头像组件表示想要更改头像:设置为需要上传(即使最终取消也将保留此标志)
+  avatarEditedByUser.value = true
+  avatarNeedsUpload.value = true
   if (isChoosing.value) {
     console.log('Already choosing, ignoring')
     return
@@ -292,9 +435,30 @@ const onSubmit = async () => {
     return
   }
 
+  // 如果头像还没有上传到服务端(非本域或本地临时文件),先上传
+  try {
+    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')
+    // 因为头像使用专属接口上传,后端不允许在 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',
@@ -302,7 +466,7 @@ const onSubmit = async () => {
         'Content-Type': 'application/json',
         'Authorization': `Bearer ${token}`
       },
-      data: form.value
+      data: payload
     })
     console.log('Update response:', response)
     const resp = response.data as any
@@ -404,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;
 }

+ 19 - 0
src/pages/public/profile/index.vue

@@ -104,6 +104,25 @@ const fetchUserInfo = async () => {
       return
     }
     if (resp && resp.code === 200 && resp.data) {
+      // 如果头像无效(不是有效的 http URL),则下载头像
+      if (!resp.data.avatar || !resp.data.avatar.startsWith('http')) {
+        const userId = resp.data.id || resp.data.userId
+        if (userId) {
+          try {
+            const downloadRes = await uni.downloadFile({
+              url: `https://wx.baiyun.work/user/avatar/${userId}`,
+              header: {
+                Authorization: `Bearer ${token}`
+              }
+            })
+            if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
+              resp.data.avatar = downloadRes.tempFilePath
+            }
+          } catch (e) {
+            console.error('Download avatar error:', e)
+          }
+        }
+      }
       user.value = resp.data
       // 保存到本地存储
       uni.setStorageSync('user_info', resp.data)