base-info.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474
  1. <template>
  2. <CustomNav title="完善基本信息" leftType="home" />
  3. <view class="complete-container">
  4. <view class="form-section">
  5. <view class="avatar-section">
  6. <view class="avatar">
  7. <button
  8. class="avatar-wrapper"
  9. :class="{ disabled: isChoosing || avatarUploading || loading }"
  10. :disabled="loading || isChoosing || avatarUploading"
  11. open-type="chooseAvatar"
  12. @tap="startChooseAvatar"
  13. @chooseavatar="onChooseAvatar"
  14. >
  15. <view class="avatar-frame">
  16. <image v-if="form.avatar" class="avatar-img" :src="form.avatar" mode="aspectFill" />
  17. <text v-else class="avatar-placeholder">点击选择头像</text>
  18. </view>
  19. </button>
  20. </view>
  21. </view>
  22. <view class="form-item">
  23. <text class="label">姓名</text>
  24. <input class="input" type="nickname" v-model="form.nickname" :disabled="loading" placeholder="请输入姓名" />
  25. </view>
  26. <view class="form-item">
  27. <text class="label">年龄</text>
  28. <input class="input" v-model="form.age" :disabled="loading" placeholder="请输入年龄" type="number" />
  29. </view>
  30. <view class="form-item">
  31. <text class="label">性别</text>
  32. <view class="radio-group">
  33. <label class="radio-item" @click="setSex(1)">
  34. <radio :checked="form.sex === 1" color="#07C160" />
  35. <text>男</text>
  36. </label>
  37. <label class="radio-item" @click="setSex(2)">
  38. <radio :checked="form.sex === 2" color="#07C160" />
  39. <text>女</text>
  40. </label>
  41. </view>
  42. </view>
  43. <view class="submit-section">
  44. <button class="submit-btn" @click="onSubmit" :disabled="loading || submitting || avatarUploading">{{ submitting ? '提交中...' : '提交' }}</button>
  45. </view>
  46. </view>
  47. </view>
  48. </template>
  49. <script setup lang="ts">
  50. import { ref } from 'vue'
  51. const isHttpUrl = (url: string) => /^https?:\/\//i.test(url)
  52. const loading = ref(false)
  53. import { onShow } from '@dcloudio/uni-app'
  54. import CustomNav from '@/components/custom-nav.vue'
  55. const form = ref({
  56. avatar: '',
  57. nickname: '',
  58. age: '',
  59. sex: 0
  60. })
  61. const submitting = ref(false)
  62. const isChoosing = ref(false)
  63. const avatarNeedsUpload = ref(false)
  64. const avatarUploading = ref(false)
  65. const avatarEditedByUser = ref(false)
  66. // 从后端拉取已有用户信息并填充表单
  67. const fetchUserInfo = async () => {
  68. try {
  69. const token = uni.getStorageSync('token')
  70. if (!token) return
  71. loading.value = true
  72. uni.showLoading({ title: '加载中...', mask: true })
  73. const response = await uni.request({
  74. url: 'https://wx.baiyun.work/user_info',
  75. method: 'POST',
  76. header: {
  77. 'Content-Type': 'application/json',
  78. 'Authorization': `Bearer ${token}`
  79. },
  80. data: {}
  81. })
  82. uni.hideLoading()
  83. console.log('fetchUserInfo response:', response)
  84. if (response.statusCode === 401) {
  85. // 未授权,跳转登录
  86. uni.removeStorageSync('token')
  87. uni.reLaunch({ url: '/pages/public/login/index' })
  88. return
  89. }
  90. const resp = response.data as any
  91. if (resp && resp.code === 200 && resp.data) {
  92. const d = resp.data
  93. // 不再判断 avatar 地址:默认从服务端下载头像(如果有 userId)
  94. // 这样即使后端返回 URL,也会下载到本地临时路径以便统一展示
  95. {
  96. const userId = d.id || d.userId
  97. if (userId) {
  98. try {
  99. const downloadRes = await uni.downloadFile({
  100. url: `https://wx.baiyun.work/user/avatar/${userId}`,
  101. header: { Authorization: `Bearer ${token}` }
  102. })
  103. if (downloadRes.statusCode === 200 && downloadRes.tempFilePath) {
  104. d.avatar = downloadRes.tempFilePath
  105. }
  106. } catch (e) {
  107. console.error('Download avatar error:', e)
  108. }
  109. }
  110. }
  111. // 填充基本字段
  112. form.value.avatar = d.avatar || form.value.avatar
  113. form.value.nickname = d.nickname || form.value.nickname
  114. form.value.age = d.age || form.value.age
  115. // sex 可能为 'MALE'/'FEMALE' 或数字/1/2
  116. if (d.sex) {
  117. if (typeof d.sex === 'string') {
  118. const s = d.sex.toUpperCase()
  119. form.value.sex = s === 'MALE' ? 1 : s === 'FEMALE' ? 2 : form.value.sex
  120. } else if (typeof d.sex === 'number') {
  121. form.value.sex = d.sex
  122. }
  123. }
  124. }
  125. } catch (err) {
  126. console.error('fetchUserInfo error:', err)
  127. } finally {
  128. uni.hideLoading()
  129. loading.value = false
  130. }
  131. }
  132. // 在页面显示时尝试填充
  133. onShow(() => {
  134. fetchUserInfo()
  135. })
  136. const onChooseAvatar = (e: any) => {
  137. if (loading.value) return
  138. console.log('onChooseAvatar called with event:', e)
  139. try {
  140. const detail = e?.detail
  141. console.log('Event detail:', detail)
  142. let url = ''
  143. if (!detail) {
  144. url = typeof e === 'string' ? e : ''
  145. console.log('No detail, using event as string:', url)
  146. } else if (typeof detail === 'string') {
  147. url = detail
  148. console.log('Detail is string:', url)
  149. } else if (detail.avatarUrl) {
  150. url = detail.avatarUrl
  151. console.log('Using detail.avatarUrl:', url)
  152. } else if (Array.isArray(detail) && detail[0]) {
  153. url = detail[0]
  154. console.log('Using first element of array:', url)
  155. }
  156. if (url) {
  157. form.value.avatar = url
  158. avatarEditedByUser.value = true
  159. avatarNeedsUpload.value = true // 标记需要上传
  160. console.log('Avatar URL updated to:', form.value.avatar)
  161. }
  162. if (detail && detail.nickName && !form.value.nickname) {
  163. form.value.nickname = detail.nickName
  164. console.log('Nickname updated from detail:', form.value.nickname)
  165. }
  166. } catch (err) {
  167. console.error('Error in onChooseAvatar:', err)
  168. } finally {
  169. isChoosing.value = false
  170. console.log('isChoosing reset to false')
  171. }
  172. }
  173. // 安全设置性别:在 loading 时禁止更改
  174. const setSex = (s: number) => {
  175. if (loading.value) return
  176. form.value.sex = s
  177. }
  178. const uploadAvatar = async (avatarUrl: string): Promise<string | null> => {
  179. try {
  180. avatarUploading.value = true
  181. const token = uni.getStorageSync('token')
  182. const uploadRes = await uni.uploadFile({
  183. url: 'https://wx.baiyun.work/user/avatar/upload',
  184. filePath: avatarUrl,
  185. name: 'file',
  186. header: {
  187. Authorization: `Bearer ${token}`
  188. }
  189. })
  190. if (uploadRes.statusCode === 200) {
  191. const resp = JSON.parse(uploadRes.data)
  192. if (resp && resp.code === 200 && resp.data) {
  193. return resp.data // 返回上传后的头像 URL
  194. }
  195. }
  196. throw new Error('Upload failed')
  197. } catch (e) {
  198. console.error('Upload avatar error:', e)
  199. avatarUploading.value = false
  200. return null
  201. }
  202. }
  203. const ensureAvatarUploaded = async (): Promise<boolean> => {
  204. if (!avatarNeedsUpload.value) return true
  205. const avatarUrl = form.value.avatar
  206. if (!avatarUrl) return false
  207. // 不再根据 avatar 地址判断跳过上传:只要 avatarNeedsUpload 为 true,就执行上传流程
  208. // 如果是远端 URL,先下载成临时文件再上传;若是本地临时文件则直接上传。
  209. if (avatarUrl.startsWith('http')) {
  210. try {
  211. uni.showLoading({ title: '准备上传头像...' })
  212. const d = await uni.downloadFile({ url: avatarUrl })
  213. uni.hideLoading()
  214. if (d.statusCode === 200 && d.tempFilePath) {
  215. const uploadedUrl = await uploadAvatar(d.tempFilePath)
  216. if (uploadedUrl) {
  217. form.value.avatar = uploadedUrl
  218. avatarNeedsUpload.value = false
  219. return true
  220. }
  221. }
  222. } catch (err) {
  223. uni.hideLoading()
  224. throw err
  225. }
  226. return false
  227. }
  228. // 否则,上传头像
  229. const uploadedUrl = await uploadAvatar(avatarUrl)
  230. if (uploadedUrl) {
  231. form.value.avatar = uploadedUrl
  232. avatarNeedsUpload.value = false
  233. return true
  234. }
  235. return false
  236. }
  237. const startChooseAvatar = () => {
  238. console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
  239. if (isChoosing.value) {
  240. console.log('Already choosing, ignoring')
  241. return
  242. }
  243. if (loading.value) return
  244. avatarEditedByUser.value = true
  245. avatarNeedsUpload.value = true
  246. isChoosing.value = true
  247. console.log('isChoosing set to true')
  248. setTimeout(() => {
  249. isChoosing.value = false
  250. console.log('Timeout: isChoosing reset to false')
  251. }, 3000)
  252. }
  253. const onSubmit = async () => {
  254. if (submitting.value) return
  255. // 检查所有必需字段
  256. if (!form.value.avatar) {
  257. uni.showToast({ title: '请选择头像', icon: 'none' })
  258. return
  259. }
  260. if (!form.value.nickname) {
  261. uni.showToast({ title: '请输入姓名', icon: 'none' })
  262. return
  263. }
  264. // 姓名格式校验:2-30 个中文、字母、·或空格
  265. const nameRegex = /^[\u4e00-\u9fa5A-Za-z·\s]{2,10}$/
  266. if (!nameRegex.test(String(form.value.nickname))) {
  267. uni.showToast({ title: '姓名格式不正确,请输入2-10位中文或字母', icon: 'none' })
  268. return
  269. }
  270. if (!form.value.age) {
  271. uni.showToast({ title: '请输入年龄', icon: 'none' })
  272. return
  273. }
  274. // 年龄为 1-120 的整数
  275. const ageNum = Number(form.value.age)
  276. if (!Number.isInteger(ageNum) || ageNum < 1 || ageNum > 120) {
  277. uni.showToast({ title: '年龄请输入1到120之间的整数', icon: 'none' })
  278. return
  279. }
  280. if (!form.value.sex) {
  281. uni.showToast({ title: '请选择性别', icon: 'none' })
  282. return
  283. }
  284. submitting.value = true
  285. try {
  286. // 确保头像已上传
  287. const avatarUploaded = await ensureAvatarUploaded()
  288. if (!avatarUploaded) {
  289. uni.showToast({ title: '头像上传失败', icon: 'none' })
  290. return
  291. }
  292. const token = uni.getStorageSync('token')
  293. // 排除 avatar 字段,因为后端不允许在 update_user_info 中包含 avatar
  294. const { avatar, ...updateData } = form.value
  295. const response = await uni.request({
  296. url: 'https://wx.baiyun.work/update_user_info',
  297. method: 'POST',
  298. header: {
  299. 'Content-Type': 'application/json',
  300. 'Authorization': `Bearer ${token}`
  301. },
  302. data: updateData
  303. })
  304. console.log('Update response:', response)
  305. const resp = response.data as any
  306. if (resp && resp.code === 200) {
  307. uni.showToast({ title: '信息更新成功', icon: 'success' })
  308. setTimeout(() => {
  309. uni.switchTab({ url: '/pages/doctor/profile/index' })
  310. }, 1500)
  311. } else {
  312. throw new Error('Update failed')
  313. }
  314. } catch (err) {
  315. console.error('Update error:', err)
  316. uni.showToast({ title: '更新失败', icon: 'error' })
  317. } finally {
  318. submitting.value = false
  319. }
  320. }
  321. </script>
  322. <style>
  323. .complete-container {
  324. min-height: 100vh;
  325. /* 为固定在顶部的 CustomNav 留出空间(状态栏 + 导航栏 44px) */
  326. padding-top: calc(var(--status-bar-height) + 44px + 40rpx);
  327. /* 保留侧边与内部间距,使用 border-box 避免计算误差 */
  328. padding-right: 40rpx;
  329. padding-left: 40rpx;
  330. /* 底部安全区:使用项目中声明的 --window-bottom 或 fallback */
  331. padding-bottom: calc(var(--window-bottom, 0px) + 40rpx);
  332. box-sizing: border-box;
  333. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  334. display: flex;
  335. flex-direction: column;
  336. align-items: center;
  337. justify-content: center;
  338. }
  339. .form-section {
  340. background-color: #fff;
  341. border-radius: 20rpx;
  342. padding: 40rpx;
  343. width: 100%;
  344. max-width: 600rpx;
  345. box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
  346. }
  347. .avatar-section {
  348. display: flex;
  349. justify-content: center;
  350. margin-bottom: 40rpx;
  351. }
  352. .avatar {
  353. width: 160rpx;
  354. height: 160rpx;
  355. border-radius: 90rpx;
  356. background-color: #eee;
  357. display: flex;
  358. align-items: center;
  359. justify-content: center;
  360. }
  361. .avatar-wrapper {
  362. width: 160rpx;
  363. height: 160rpx;
  364. border-radius: 90rpx;
  365. overflow: hidden;
  366. display: flex;
  367. align-items: center;
  368. justify-content: center;
  369. padding: 0;
  370. border: 2rpx solid #07C160;
  371. background: #fff;
  372. }
  373. .avatar-wrapper.disabled {
  374. opacity: 0.5;
  375. }
  376. .avatar-frame {
  377. width: 100%;
  378. height: 100%;
  379. border-radius: 60rpx;
  380. overflow: hidden;
  381. display: flex;
  382. align-items: center;
  383. justify-content: center;
  384. }
  385. .avatar-img {
  386. width: 100%;
  387. height: 100%;
  388. object-fit: cover;
  389. }
  390. .avatar-placeholder {
  391. color: #999;
  392. font-size: 24rpx;
  393. }
  394. .form-item {
  395. margin-bottom: 30rpx;
  396. }
  397. .label {
  398. display: block;
  399. font-size: 32rpx;
  400. color: #333;
  401. margin-bottom: 10rpx;
  402. }
  403. .input {
  404. width: 100%;
  405. height: 80rpx;
  406. border: 1rpx solid #ddd;
  407. border-radius: 8rpx;
  408. padding: 0 20rpx;
  409. font-size: 32rpx;
  410. box-sizing: border-box;
  411. max-width: 100%;
  412. }
  413. .radio-group {
  414. display: flex;
  415. gap: 40rpx;
  416. }
  417. .radio-item {
  418. display: flex;
  419. align-items: center;
  420. font-size: 32rpx;
  421. color: #333;
  422. }
  423. .submit-section {
  424. margin-top: 60rpx;
  425. }
  426. .submit-btn {
  427. width: 100%;
  428. background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
  429. color: #fff;
  430. border-radius: 12rpx;
  431. font-size: 32rpx;
  432. line-height: 80rpx;
  433. border: none;
  434. }
  435. .submit-btn:disabled {
  436. opacity: 0.5;
  437. }
  438. </style>