Просмотр исходного кода

feat(doctor): 实现医生端个人中心及首页功能

- 新增医生个人中心页面,支持头像、昵称、年龄、性别等信息展示与编辑
- 完成医生首页改版,新增患者管理、复诊管理快捷入口
- 实现今日提醒模块,展示待处理复诊与异常患者数量
- 添加患者动态列表,实时展示患者健康数据更新情况
- 支持医生个人信息完善流程,包括头像选择与基础信息校验
- 更新路由配置,添加医生端相关页面路径与导航栏标题
- 优化医生端TabBar配置,区分医生与患者个人中心入口
- 完善用户信息获取逻辑,支持token验证与自动跳转登录页
- 实现退出登录功能,清理本地缓存并返回首页
mcbaiyun 1 месяц назад
Родитель
Сommit
a489cf695e

+ 1 - 1
src/components/tab-bar.vue

@@ -33,7 +33,7 @@ const roleTabs: RoleTabs = {
   2: [
     { icon: '/static/icons/remixicon/home-3-line.svg', text: '首页', url: '/pages/doctor/index/index' },
     { icon: '/static/icons/remixicon/settings-line.svg', text: '后台', url: '/pages/public/health/index' }, // 暂时使用公共健康页,后续可创建医生患者管理页
-    { icon: '/static/icons/remixicon/account-circle-line.svg', text: '我的', url: '/pages/public/profile/index' } // 暂时使用公共profile,后续可创建医生profile
+    { icon: '/static/icons/remixicon/account-circle-line.svg', text: '我的', url: '/pages/doctor/profile/index' } // 暂时使用公共profile,后续可创建医生profile
   ],
   // 患者
   3: [

+ 16 - 0
src/pages.json

@@ -67,12 +67,24 @@
 				"navigationBarTitleText": "个人中心-患者端"
 			}
 		},
+		{
+			"path": "pages/doctor/profile/index",
+			"style": {
+				"navigationBarTitleText": "个人中心-医生端"
+			}
+		},
 		{
 			"path": "pages/patient/profile/infos/base-info",
 			"style": {
 				"navigationBarTitleText": "基本信息设定"
 			}
 		},
+		{
+			"path": "pages/doctor/profile/infos/base-info",
+			"style": {
+				"navigationBarTitleText": "基本信息设定"
+			}
+		}
 		{
 			"path": "pages/patient/profile/infos/patient-filing",
 			"style": {
@@ -133,6 +145,10 @@
 				"pagePath": "pages/patient/profile/index",
 				"text": "个人中心-患者端"
 			},
+			{
+				"pagePath": "pages/doctor/profile/index",
+				"text": "个人中心-医生端"
+			},
 			{
 				"pagePath": "pages/doctor/index/index",
 				"text": "医生首页"

+ 447 - 69
src/pages/doctor/index/index.vue

@@ -1,59 +1,242 @@
 <template>
-  <CustomNav title="首页" leftType="scan" @scan="handleScan" />
-  <view class="content">
-    <!-- 原始页面已移至此文件,保留以备参考 -->
-    <swiper class="banner-swiper" :indicator-dots="true" :autoplay="true" :interval="3000" :circular="true">
-      <swiper-item v-for="(img, idx) in bannerImages" :key="idx">
-        <image :src="img" class="banner-img" mode="aspectFill" />
-      </swiper-item>
-    </swiper>
-    <view class="card-list">
-      <view class="card" v-for="(card, idx) in cards" :key="idx">
-        <view class="card-title">{{ card.title }}</view>
-        <view class="card-desc">{{ card.desc }}</view>
+  <CustomNav title="医生首页" leftType="scan" @scan="handleScan" :opacity="0" />
+  <view class="page-container">
+    <view class="content">
+      <view class="user-info">
+        <view class="avatar-section">
+          <view class="avatar">
+            <view class="avatar-frame">
+              <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+            </view>
+          </view>
+          <view class="user-details">
+            <text class="username">{{ user.nickname || '未登录' }}</text>
+            <text class="user-title" v-if="user.title">职称: {{ user.title }}</text>
+          </view>
+        </view>
       </view>
+
+      <view class="function-container">
+        <view class="function-row">
+          <view class="function-item blue" @click="onItemClick('病人管理')">
+            <view class="item-content">
+              <view class="title-row">
+                <view class="item-line"></view>
+                <text class="item-title">病人管理</text>
+              </view>
+              <text class="item-desc">管理慢性病患者</text>
+            </view>
+          </view>
+          <view class="function-item orange" @click="onItemClick('复诊管理')">
+            <view class="item-content">
+              <view class="title-row">
+                <view class="item-line"></view>
+                <text class="item-title">复诊管理</text>
+              </view>
+              <text class="item-desc">安排复诊随访</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="today-reminder-card">
+        <view class="card-header">
+          <text class="card-title">今日提醒</text>
+        </view>
+        <view class="card-content">
+          <view class="reminder-item" @click="onItemClick('复诊管理')">
+            <view class="reminder-icon">
+              <image src="/static/icons/remixicon/time-line.svg" class="icon" />
+            </view>
+            <view class="reminder-text">
+              <text class="reminder-number">{{ todayReminders.followUpCount }}</text>
+              <text class="reminder-label">待处理复诊</text>
+            </view>
+          </view>
+          <view class="reminder-item" @click="onItemClick('病人管理')">
+            <view class="reminder-icon">
+              <image src="/static/icons/remixicon/alert-line.svg" class="icon" />
+            </view>
+            <view class="reminder-text">
+              <text class="reminder-number">{{ todayReminders.abnormalCount }}</text>
+              <text class="reminder-label">异常患者</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <view class="patient-activity-card">
+        <view class="card-header">
+          <text class="card-title">患者动态</text>
+        </view>
+        <view class="activity-card-content">
+          <view class="activity-item" v-for="(activity, index) in patientActivities" :key="index">
+            <view class="activity-avatar">
+              <image :src="activity.patientAvatar" class="avatar-img" mode="aspectFill" />
+            </view>
+            <view class="activity-text">
+              <text class="activity-desc">{{ activity.desc }}</text>
+              <text class="activity-time">{{ activity.time }}</text>
+            </view>
+          </view>
+          <view v-if="patientActivities.length === 0" class="no-activity">
+            <text>暂无患者动态</text>
+          </view>
+        </view>
+      </view>
+
     </view>
   </view>
   <TabBar />
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { ref, computed } from 'vue'
 import { onShow } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import TabBar from '@/components/tab-bar.vue'
-import { isLoggedIn as checkLogin, getRole } from '@/composables/useAuth'
 
-const title = ref('Hello')
+const user = ref<{ avatar?: string; nickname?: string; title?: string }>({})
 
-const bannerImages = [
-  '/static/carousel/BHFIIABBCDJII-5kCEkD6zh9.png',
-  '/static/carousel/BHFIIABBDGHEA-wtWLrLS75o.png',
-  '/static/carousel/BHFIIABBHJBAH-yDeckRiiQP.png'
-]
+const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
 
-const cards = [
-  { title: '健康档案', desc: '管理您的健康信息' },
-  { title: '慢病管理', desc: '查看慢病相关数据' },
-  { title: '健康咨询', desc: '在线咨询医生' },
-  { title: '用药提醒', desc: '设置用药提醒' }
-]
+const avatarSrc = computed(() => {
+  const a = user.value?.avatar
+  if (!a) return defaultAvatarUrl
+  try {
+    const s = String(a)
+    if (/^(https?:\/\/|data:|wxfile:\/\/|file:\/\/|\/static\/)/i.test(s)) {
+      return s
+    }
+    if (/^(\.|\/|temp)/i.test(s)) return s
+  } catch (e) {
+    // fallback
+  }
+  return defaultAvatarUrl
+})
 
-onShow(() => {
+const todayReminders = ref({
+  followUpCount: 0,
+  abnormalCount: 0
+})
+
+const patientActivities = ref([
+  {
+    desc: '患者张三血糖数据异常,请关注',
+    time: '10分钟前',
+    patientAvatar: 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+  },
+  {
+    desc: '患者李四完成了今日复诊',
+    time: '1小时前',
+    patientAvatar: 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+  },
+  {
+    desc: '患者王五上传了血压数据',
+    time: '2小时前',
+    patientAvatar: 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+  }
+])
+
+const loadUser = () => {
   try {
-    const logged = checkLogin()
-    if (!logged) {
-      return
+    const u = uni.getStorageSync('user_info')
+    if (u) {
+      user.value = u
     }
+  } catch (e) {
+    // ignore
+  }
+}
 
-    const r = getRole()
-    if (r === 3) {
-      console.log('[index] redirecting to patient index, role=', r)
-      uni.reLaunch({ url: '/pages/patient/index/index' })
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    uni.showLoading({ title: '加载中...' })
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    uni.hideLoading()
+    console.log('User info response:', response)
+    const resp = response.data as any
+    if (response.statusCode === 401) {
+      // Token 无效,清除并跳转登录
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('role')
+      user.value = {}
+      uni.reLaunch({ url: '/pages/public/login/index' })
       return
     }
+    if (resp && resp.code === 200 && resp.data) {
+      user.value = resp.data
+      uni.setStorageSync('user_info', resp.data)
+      if (!resp.data.nickname || !resp.data.avatar) {
+        uni.navigateTo({ url: '/pages/doctor/profile/index' })
+      }
+    }
+  } catch (err) {
+    uni.hideLoading()
+    console.error('Fetch user info error:', err)
+  }
+}
+
+const fetchTodayReminders = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/doctor/today_reminders',
+      method: 'GET',
+      header: {
+        'Authorization': `Bearer ${token}`
+      }
+    })
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      todayReminders.value = resp.data
+    }
   } catch (err) {
-    console.error('检查登录态时出错:', err)
+    console.error('Fetch today reminders error:', err)
+  }
+}
+
+const fetchPatientActivities = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/doctor/patient_activities',
+      method: 'GET',
+      header: {
+        'Authorization': `Bearer ${token}`
+      }
+    })
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      patientActivities.value = resp.data
+    }
+  } catch (err) {
+    console.error('Fetch patient activities error:', err)
+  }
+}
+
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  } else {
+    fetchUserInfo()
+    fetchTodayReminders()
+    fetchPatientActivities()
   }
 })
 
@@ -66,74 +249,269 @@ function handleScan(res: any) {
     uni.showToast({ title: '未识别到有效内容', icon: 'none' })
   }
 }
+
+function onItemClick(type: string) {
+  if (type === '病人管理') {
+    uni.navigateTo({ url: '/pages/doctor/manage/index' })
+  } else if (type === '复诊管理') {
+    uni.navigateTo({ url: '/pages/doctor/manage/followup' })
+  } else {
+    uni.showToast({ title: '功能正在开发中', icon: 'none' })
+  }
+}
 </script>
 
 <style>
+.page-container {
+  min-height: 100vh;
+  padding-top: calc(var(--status-bar-height) + 44px);
+  padding-bottom: 100rpx;
+  box-sizing: border-box;
+  justify-content: center;
+  align-items: center;
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+}
+
 .content {
+  width: 100%;
+}
+
+.user-info {
+  /* background-color: #fff; */
+  padding: 40rpx;
+  margin-top: 0rpx;
+}
+
+.avatar-section {
   display: flex;
-  flex-direction: column;
   align-items: center;
-  justify-content: flex-start;
-  padding-top: calc(var(--status-bar-height) + 44px);
-  height: calc(100vh - var(--status-bar-height) - 44px);
-  background: #f7f8fa;
 }
 
-.banner-swiper {
-  width: 670rpx;
-  max-width: 100vw;
-  height: 400rpx;
-  margin: 20rpx auto 30rpx auto;
-  border-radius: 16rpx;
+.avatar {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  border: 1px solid rgba(128, 128, 128, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 30rpx;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
   overflow: hidden;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.08);
-  background: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 }
 
-.banner-img {
+.avatar-img {
   width: 100%;
   height: 100%;
+  object-fit: cover;
+}
+
+.user-details {
+  flex: 1;
+}
+
+.username {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.user-title {
+  font-size: 28rpx;
+  color: #666;
   display: block;
+  margin-bottom: 10rpx;
 }
 
-.card-list {
-  width: 90%;
+.function-container {
+  padding-inline: 20rpx;
+}
+
+.function-row {
   display: flex;
-  flex-direction: column;
-  gap: 32rpx;
-  padding: 0 40rpx;
-  margin: 0 auto;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
 }
 
-.card {
-  background: #fff;
-  border-radius: 16rpx;
-  box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06);
-  padding: 40rpx 32rpx;
-  box-sizing: border-box;
+.function-row:last-child {
+  margin-bottom: 0;
+}
+
+.function-item {
+  flex: 1;
+  height: 160rpx;
+  background-color: #fff;
+  border-radius: 20rpx;
+  margin: 0 10rpx;
+  position: relative;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
+  overflow: hidden;
+}
+
+.item-content {
+  position: absolute;
+  top: 20rpx;
+  left: 20rpx;
   display: flex;
   flex-direction: column;
 }
 
-.card-title {
+.title-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 10rpx;
+}
+
+.item-line {
+  width: 8rpx;
+  height: 48rpx;
+  margin-right: 15rpx;
+  border-radius: 10rpx;
+}
+
+.blue .item-line {
+  background-color: #3742fa;
+}
+
+.orange .item-line {
+  background-color: #ffa502;
+}
+
+.item-title {
   font-size: 36rpx;
   font-weight: bold;
   color: #333;
-  margin-bottom: 16rpx;
 }
 
-.card-desc {
+.item-desc {
   font-size: 28rpx;
-  color: #888;
+  color: #666;
+}
+
+.today-reminder-card {
+  background-color: #fff;
+  border-radius: 20rpx;
+  margin: 20rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
+  overflow: hidden;
+}
+
+.card-header {
+  padding: 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.card-title {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.card-content {
+  padding: 20rpx;
+  display: flex;
+  justify-content: space-around;
+}
+
+.reminder-item {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  padding: 20rpx;
+  border-radius: 10rpx;
+  background-color: #f9f9f9;
+  margin: 0 10rpx;
 }
 
-.text-area {
+.reminder-icon {
+  width: 60rpx;
+  height: 60rpx;
+  margin-right: 20rpx;
+}
+
+.icon {
+  width: 100%;
+  height: 100%;
+}
+
+.reminder-text {
+  display: flex;
+  flex-direction: column;
+}
+
+.reminder-number {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.activity-card-content {
+  padding: 20rpx;
+  display: block;
+}
+
+.patient-activity-card {
+  background-color: #fff;
+  border-radius: 20rpx;
+  margin: 20rpx;
+  box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.1);
+  overflow: hidden;
+}
+
+.activity-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+  padding: 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.activity-item:last-child {
+  margin-bottom: 0;
+  border-bottom: none;
+}
+
+.activity-avatar {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  border: 1px solid rgba(128, 128, 128, 0.5);
+  margin-right: 20rpx;
+  overflow: hidden;
   display: flex;
+  align-items: center;
   justify-content: center;
 }
 
-.title {
-  font-size: 36rpx;
-  color: #8f8f94;
+.activity-text {
+  flex: 1;
+}
+
+.activity-desc {
+  font-size: 32rpx;
+  color: #333;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.activity-time {
+  font-size: 28rpx;
+  color: #666;
+}
+
+.no-activity {
+  padding: 40rpx;
+  text-align: center;
+  color: #999;
 }
 </style>

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

@@ -0,0 +1,280 @@
+<template>
+  <CustomNav title="个人中心" leftType="none" />
+  <view class="profile-container">
+    <view class="header">
+      <view class="avatar-section">
+        <view class="avatar">
+          <view class="avatar-frame">
+            <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+          </view>
+        </view>
+        <view class="user-info">
+          <text class="username">{{ user.nickname || '医生' }}</text>
+          <text class="user-id" v-if="user.openid">ID: {{ user.openid }}</text>
+        </view>
+      </view>
+    </view>
+
+
+
+    <view class="menu-list">
+      <view class="menu-item" @click="onMenuClick('edit')">
+        <text class="menu-text">编辑信息</text>
+        <text class="menu-arrow"></text>
+      </view>
+      <view class="menu-item" @click="onMenuClick('settings')">
+        <text class="menu-text">设置</text>
+        <text class="menu-arrow"></text>
+      </view>
+      <view class="menu-item" @click="onMenuClick('about')">
+        <text class="menu-text">关于我们</text>
+        <text class="menu-arrow"></text>
+      </view>
+    </view>
+
+    <view class="logout-section">
+      <button class="logout-btn" @click="onLogout">退出登录</button>
+    </view>
+  </view>
+  <TabBar />
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { computed } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+import TabBar from '@/components/tab-bar.vue'
+import { isLoggedIn as checkLogin, getRole } from '@/composables/useAuth'
+
+const title = ref('个人中心')
+
+const user = ref<{ avatar?: string; nickname?: string; openid?: string }>({})
+
+const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+
+// 计算一个安全的 avatar src:优先使用有效的 user.avatar,否则使用默认头像
+const avatarSrc = computed(() => {
+  const a = user.value?.avatar
+  if (!a) return defaultAvatarUrl
+  try {
+    const s = String(a)
+    // 常见有效前缀:http(s), data:, wxfile://, file://, /static
+    if (/^(https?:\/\/|data:|wxfile:\/\/|file:\/\/|\/static\/)/i.test(s)) {
+      return s
+    }
+    // 有时候小程序临时路径也以 / 开头或以 temp 开头,尽量允许以 ./ 或 / 开头
+    if (/^(\.|\/|temp)/i.test(s)) return s
+  } catch (e) {
+    // fallback
+  }
+  return defaultAvatarUrl
+})
+
+const loadUser = () => {
+  try {
+    const u = uni.getStorageSync('user_info')
+    if (u) {
+      user.value = u
+    }
+  } catch (e) {
+    // ignore
+  }
+}
+
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    uni.showLoading({ title: '加载中...' })
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    uni.hideLoading()
+    console.log('User info response:', response)
+    const resp = response.data as any
+    if (response.statusCode === 401) {
+      // Token 无效,清除并跳转登录
+      uni.removeStorageSync('token')
+      uni.removeStorageSync('role')
+      user.value = {}
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
+    }
+    if (resp && resp.code === 200 && resp.data) {
+      user.value = resp.data
+      // 保存到本地存储
+      uni.setStorageSync('user_info', resp.data)
+    }
+  } catch (err) {
+    uni.hideLoading()
+    console.error('Fetch user info error:', err)
+  }
+}
+
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  const token = uni.getStorageSync('token')
+  if (!token) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/public/login/index' })
+  } else {
+    fetchUserInfo()
+    // 检查角色,如果是患者,跳转到患者个人中心
+    try {
+      const r = getRole()
+      if (r === 3) {
+        console.log('[profile/index] redirecting to patient profile, role=', r)
+        uni.reLaunch({ url: '/pages/patient/profile/index' })
+        return
+      }
+    } catch (err) {
+      console.error('检查角色时出错:', err)
+    }
+  }
+})
+
+const onMenuClick = (type: string) => {
+  console.log('Menu clicked:', type)
+  if (type === 'edit') {
+    uni.navigateTo({ url: '/pages/doctor/profile/infos/base-info' })
+  } else {
+    uni.showToast({
+      title: `${type} 功能开发中`,
+      icon: 'none'
+    })
+  }
+}
+
+const onLogout = () => {
+  uni.showModal({
+    title: '提示',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
+        user.value = {}
+        uni.showToast({ title: '已退出登录', icon: 'none' })
+        uni.reLaunch({ url: '/pages/public/index/index' })
+      }
+    }
+  })
+}
+</script>
+
+<style>
+.profile-container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-top: calc(var(--status-bar-height) + 44px);
+}
+
+.header {
+  background-color: #fff;
+  padding: 40rpx;
+  margin-bottom: 20rpx;
+}
+
+.avatar-section {
+  display: flex;
+  align-items: center;
+}
+
+.avatar {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 50%;
+  /* background-color: #007aff; */
+  border: 1px solid rgba(128, 128, 128, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 30rpx;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  overflow: hidden; /* 裁切超出部分,避免溢出 */
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover; /* 在部分平台下生效,保证图片填充同时裁切 */
+}
+
+.avatar-text {
+  color: #fff;
+  font-size: 32rpx;
+  font-weight: bold;
+}
+
+.user-info {
+  flex: 1;
+}
+
+.username {
+  font-size: 36rpx;
+  font-weight: bold;
+  color: #333;
+  display: block;
+  margin-bottom: 10rpx;
+}
+
+.user-id {
+  font-size: 28rpx;
+  color: #666;
+}
+
+.menu-list {
+  background-color: #fff;
+  margin-bottom: 20rpx;
+}
+
+.menu-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 30rpx 40rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.menu-item:last-child {
+  border-bottom: none;
+}
+
+.menu-text {
+  font-size: 32rpx;
+  color: #333;
+}
+
+.menu-arrow {
+  font-size: 28rpx;
+  color: #ccc;
+}
+
+.logout-section {
+  padding: 40rpx;
+}
+
+.logout-btn {
+  width: 100%;
+  background-color: #ff4757;
+  color: #fff;
+  border-radius: 8rpx;
+  font-size: 32rpx;
+  line-height: 80rpx;
+}
+</style>

+ 365 - 0
src/pages/doctor/profile/infos/base-info.vue

@@ -0,0 +1,365 @@
+<template>
+  <CustomNav title="完善基本信息" leftType="home" />
+  <view class="complete-container">
+    <view class="form-section">
+      <view class="avatar-section">
+        <view class="avatar">
+          <button
+            class="avatar-wrapper"
+            :class="{ disabled: isChoosing }"
+            :disabled="isChoosing"
+            open-type="chooseAvatar"
+            @tap="startChooseAvatar"
+            @chooseavatar="onChooseAvatar"
+          >
+            <view class="avatar-frame">
+              <image v-if="form.avatar" class="avatar-img" :src="form.avatar" mode="aspectFill" />
+              <text v-else class="avatar-placeholder">点击选择头像</text>
+            </view>
+          </button>
+        </view>
+      </view>
+
+      <view class="form-item">
+        <text class="label">姓名</text>
+        <input class="input" type="nickname" v-model="form.nickname" placeholder="请输入姓名" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">年龄</text>
+        <input class="input" v-model="form.age" placeholder="请输入年龄" type="number" />
+      </view>
+
+      <view class="form-item">
+        <text class="label">性别</text>
+        <view class="radio-group">
+          <label class="radio-item" @click="form.sex = 1">
+            <radio :checked="form.sex === 1" color="#07C160" />
+            <text>男</text>
+          </label>
+          <label class="radio-item" @click="form.sex = 2">
+            <radio :checked="form.sex === 2" color="#07C160" />
+            <text>女</text>
+          </label>
+        </view>
+      </view>
+
+      <view class="submit-section">
+        <button class="submit-btn" @click="onSubmit" :disabled="submitting">{{ submitting ? '提交中...' : '提交' }}</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import CustomNav from '@/components/custom-nav.vue'
+
+const form = ref({
+  avatar: '',
+  nickname: '',
+  age: '',
+  sex: 0
+})
+
+const submitting = ref(false)
+const isChoosing = ref(false)
+
+// 从后端拉取已有用户信息并填充表单
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    uni.showLoading({ title: '加载中...' })
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    uni.hideLoading()
+    console.log('fetchUserInfo response:', response)
+    if (response.statusCode === 401) {
+      // 未授权,跳转登录
+      uni.removeStorageSync('token')
+      uni.reLaunch({ url: '/pages/public/login/index' })
+      return
+    }
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      const d = resp.data
+      // 填充基本字段
+      form.value.avatar = d.avatar || form.value.avatar
+      form.value.nickname = d.nickname || form.value.nickname
+      form.value.age = d.age || form.value.age
+      // sex 可能为 'MALE'/'FEMALE' 或数字/1/2
+      if (d.sex) {
+        if (typeof d.sex === 'string') {
+          const s = d.sex.toUpperCase()
+          form.value.sex = s === 'MALE' ? 1 : s === 'FEMALE' ? 2 : form.value.sex
+        } else if (typeof d.sex === 'number') {
+          form.value.sex = d.sex
+        }
+      }
+    }
+  } catch (err) {
+    uni.hideLoading()
+    console.error('fetchUserInfo error:', err)
+  }
+}
+
+// 在页面显示时尝试填充
+onShow(() => {
+  fetchUserInfo()
+})
+
+const onChooseAvatar = (e: any) => {
+  console.log('onChooseAvatar called with event:', e)
+  try {
+    const detail = e?.detail
+    console.log('Event detail:', detail)
+    let url = ''
+    if (!detail) {
+      url = typeof e === 'string' ? e : ''
+      console.log('No detail, using event as string:', url)
+    } else if (typeof detail === 'string') {
+      url = detail
+      console.log('Detail is string:', url)
+    } else if (detail.avatarUrl) {
+      url = detail.avatarUrl
+      console.log('Using detail.avatarUrl:', url)
+    } else if (Array.isArray(detail) && detail[0]) {
+      url = detail[0]
+      console.log('Using first element of array:', url)
+    }
+    if (url) {
+      form.value.avatar = url
+      console.log('Avatar URL updated to:', form.value.avatar)
+    }
+    if (detail && detail.nickName && !form.value.nickname) {
+      form.value.nickname = detail.nickName
+      console.log('Nickname updated from detail:', form.value.nickname)
+    }
+  } catch (err) {
+    console.error('Error in onChooseAvatar:', err)
+  } finally {
+    isChoosing.value = false
+    console.log('isChoosing reset to false')
+  }
+}
+
+const startChooseAvatar = () => {
+  console.log('startChooseAvatar called, current isChoosing:', isChoosing.value)
+  if (isChoosing.value) {
+    console.log('Already choosing, ignoring')
+    return
+  }
+  isChoosing.value = true
+  console.log('isChoosing set to true')
+  setTimeout(() => {
+    isChoosing.value = false
+    console.log('Timeout: isChoosing reset to false')
+  }, 3000)
+}
+
+const onSubmit = async () => {
+  if (submitting.value) return
+
+  // 检查所有必需字段
+  if (!form.value.avatar) {
+    uni.showToast({ title: '请选择头像', icon: 'none' })
+    return
+  }
+  if (!form.value.nickname) {
+    uni.showToast({ title: '请输入姓名', icon: 'none' })
+    return
+  }
+  // 姓名格式校验:2-30 个中文、字母、·或空格
+  const nameRegex = /^[\u4e00-\u9fa5A-Za-z·\s]{2,10}$/
+  if (!nameRegex.test(String(form.value.nickname))) {
+    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.sex) {
+    uni.showToast({ title: '请选择性别', icon: 'none' })
+    return
+  }
+
+  submitting.value = true
+  try {
+    const token = uni.getStorageSync('token')
+    const response = await uni.request({
+      url: 'https://wx.baiyun.work/update_user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: form.value
+    })
+    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' })
+      }, 1500)
+    } else {
+      throw new Error('Update failed')
+    }
+  } catch (err) {
+    console.error('Update error:', err)
+    uni.showToast({ title: '更新失败', icon: 'error' })
+  } finally {
+    submitting.value = false
+  }
+}
+</script>
+
+<style>
+.complete-container {
+  min-height: 100vh;
+  /* 为固定在顶部的 CustomNav 留出空间(状态栏 + 导航栏 44px) */
+  padding-top: calc(var(--status-bar-height) + 44px + 40rpx);
+  /* 保留侧边与内部间距,使用 border-box 避免计算误差 */
+  padding-right: 40rpx;
+  padding-left: 40rpx;
+  /* 底部安全区:使用项目中声明的 --window-bottom 或 fallback */
+  padding-bottom: calc(var(--window-bottom, 0px) + 40rpx);
+  box-sizing: border-box;
+  background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.form-section {
+  background-color: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+  width: 100%;
+  max-width: 600rpx;
+  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
+}
+
+.avatar-section {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+
+.avatar {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 90rpx;
+  background-color: #eee;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-wrapper {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 90rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 2rpx solid #07C160;
+  background: #fff;
+}
+
+.avatar-wrapper.disabled {
+  opacity: 0.5;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 60rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-placeholder {
+  color: #999;
+  font-size: 24rpx;
+}
+
+.form-item {
+  margin-bottom: 30rpx;
+}
+
+.label {
+  display: block;
+  font-size: 32rpx;
+  color: #333;
+  margin-bottom: 10rpx;
+}
+
+.input {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  font-size: 32rpx;
+  box-sizing: border-box;
+  max-width: 100%;
+}
+
+.radio-group {
+  display: flex;
+  gap: 40rpx;
+}
+
+.radio-item {
+  display: flex;
+  align-items: center;
+  font-size: 32rpx;
+  color: #333;
+}
+
+.submit-section {
+  margin-top: 60rpx;
+}
+
+.submit-btn {
+  width: 100%;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  line-height: 80rpx;
+  border: none;
+}
+
+.submit-btn:disabled {
+  opacity: 0.5;
+}
+</style>