Przeglądaj źródła

feat(login): 重构登录页面并实现微信登录流程

- 新增角色选择登录方式,支持医生、患者及家属三类用户
- 移除原有头像选择与昵称输入逻辑
- 实现微信一键登录并与后端交互获取 token
- 登录成功后自动跳转至首页并存储 token 和角色信息
- 更新页面样式以适配新的登录流程
- 完善用户信息获取逻辑并在缺少信息时引导至完善页
- 调整登出功能以清除 token 和角色缓存
- 新增 pages.json 中的完善信息页面路径配置
mcbaiyun 2 miesięcy temu
rodzic
commit
cab8da8953

+ 6 - 0
src/pages.json

@@ -23,6 +23,12 @@
 			"style": {
 				"navigationBarTitleText": "个人中心"
 			}
+		},
+		{
+			"path": "pages/profile/complete-info",
+			"style": {
+				"navigationBarTitleText": "完善信息"
+			}
 		}
 	],
 	"globalStyle": {

+ 333 - 0
src/pages/login/login-unuse.vue

@@ -0,0 +1,333 @@
+<template>
+  <CustomNav title="登录" leftType="home" />
+  <view class="login-container">
+    <view class="spacer top" />
+    <view class="login-card">
+      <view class="title-section">
+        <text class="page-title">欢迎使用慢病管理APP</text>
+        </view>
+
+      <view class="avatar-area">
+        <text class="section-label">请点击下方选择您的头像</text>
+        <!-- only available in Weixin Mini Program: chooseAvatar open-type -->
+        <!-- 使用 tap 事件在打开前设置锁,防止重复调用导致 chooseAvatar:fail 错误 -->
+        <button
+          class="avatar-wrapper"
+          :class="{ disabled: isChoosing }"
+          :disabled="isChoosing"
+          open-type="chooseAvatar"
+          @tap="startChooseAvatar"
+          @chooseavatar="onChooseAvatar"
+        >
+          <view class="avatar-frame">
+            <image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
+          </view>
+        </button>
+        </view>
+
+      <form @submit.prevent="onSubmit">
+        <view class="input-section">
+          <text class="section-label">请输入您的名字</text>
+          <input class="nickname-input" type="nickname" placeholder="请点击此处进行输入" v-model="nickname" @blur="onNicknameBlur" />
+        </view>
+
+        <button form-type="submit" class="login-btn">授权登录</button>
+      </form>
+    </view>
+    <view class="spacer bottom" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onMounted } from 'vue'
+import PageTitle from '@/components/PageTitle.vue'
+import CustomNav from '@/components/CustomNav.vue'
+
+// 页面标题,用于导航栏显示
+const title = ref('登录')
+
+// 默认头像URL,用于未选择头像时的显示
+const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+const avatarUrl = ref(defaultAvatarUrl)
+// 防止重复触发 chooseAvatar 的并发锁,避免多次调用导致错误
+const isChoosing = ref(false)
+const nickname = ref('')
+
+// 处理chooseAvatar事件回调(微信小程序)
+// 此函数在用户选择头像后被调用,用于更新头像URL和可能的昵称
+function onChooseAvatar(e: any) {
+  console.log('onChooseAvatar called with event:', e) // 调试输出:记录事件对象
+  // 在微信小程序中,e.detail 可能包含 avatarUrl(字符串)或更复杂结构
+  try {
+    const detail = e?.detail
+    console.log('Event detail:', detail) // 调试输出:检查detail内容
+    let url = ''
+    if (!detail) {
+      // 兼容某些平台直接传回字符串
+      url = typeof e === 'string' ? e : ''
+      console.log('No detail, using event as string:', url) // 调试输出:无detail时的处理
+    } else if (typeof detail === 'string') {
+      url = detail
+      console.log('Detail is string:', url) // 调试输出:detail为字符串
+    } else if (detail.avatarUrl) {
+      url = detail.avatarUrl
+      console.log('Using detail.avatarUrl:', url) // 调试输出:使用avatarUrl字段
+    } else if (Array.isArray(detail) && detail[0]) {
+      url = detail[0]
+      console.log('Using first element of array:', url) // 调试输出:数组第一个元素
+    }
+    if (url) {
+      avatarUrl.value = url
+      console.log('Avatar URL updated to:', avatarUrl.value) // 调试输出:头像URL更新
+    }
+    // 如果 detail 中包含昵称(某些平台可能提供),尝试填充
+    if (detail && detail.nickName && !nickname.value) {
+      nickname.value = detail.nickName
+      console.log('Nickname updated from detail:', nickname.value) // 调试输出:昵称更新
+    }
+  } catch (err) {
+    console.error('Error in onChooseAvatar:', err) // 调试输出:捕获错误
+    // ignore
+  } finally {
+    // 无论成功或失败,都在回调后清理并发锁
+    isChoosing.value = false
+    console.log('isChoosing reset to false') // 调试输出:重置锁
+  }
+}
+
+// 在点击(tap)打开 chooseAvatar 之前设置锁,防止重复打开
+function 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') // 调试输出:设置锁
+  // 为了避免因回调丢失导致一直锁住,设置一个安全超时(3s)作为兜底
+  setTimeout(() => {
+    isChoosing.value = false
+    console.log('Timeout: isChoosing reset to false') // 调试输出:超时重置锁
+  }, 3000)
+}
+
+// 处理昵称输入框失去焦点事件
+function onNicknameBlur() {
+  console.log('onNicknameBlur called, current nickname:', nickname.value) // 调试输出:昵称输入
+  // 微信会在 onBlur 时进行安全检测,若不通过会清空输入,这里仅作默认处理
+}
+
+// 处理表单提交事件
+async function onSubmit() {
+  console.log('onSubmit called with avatarUrl:', avatarUrl.value, 'nickname:', nickname.value) // 调试输出:提交数据
+  
+  // 调用 uni.login 获取微信登录凭证
+  uni.login({
+    provider: 'weixin', // 指定微信登录
+    success: async (res) => {
+      console.log('uni.login success:', res) // 调试输出:登录成功
+      const code = res.code // 获取 code
+      
+      try {
+        // 调用后端接口获取 openid(适配新版响应格式)
+        const response = await uni.request({
+          url: 'http://127.0.0.1:8080/get_openid',
+          method: 'POST',
+          data: { code: code },
+          header: { 'Content-Type': 'application/json' }
+        })
+
+        console.log('Backend response:', response) // 调试输出:后端响应
+
+        // 适配新版后端格式 { code, message, data: { openid } }
+        const resp = response.data as any
+        if (resp && resp.code === 200 && resp.data && resp.data.openid) {
+          // 获取到 openid,保存用户信息
+          const user = {
+            avatar: avatarUrl.value,
+            nickname: nickname.value || '微信用户',
+            code: code,
+            openid: resp.data.openid
+          }
+          console.log('User object to save:', user) // 调试输出:用户对象
+          uni.setStorageSync('user_info', user)
+
+          // 导航到个人中心
+          try {
+            console.log('Attempting switchTab to profile') // 调试输出:尝试切换标签页
+            uni.switchTab({ url: '/pages/profile/profile' })
+          } catch (e) {
+            console.log('switchTab failed, using navigateTo:', e) // 调试输出:切换失败,使用导航
+            uni.navigateTo({ url: '/pages/profile/profile' })
+          }
+        } else {
+          // 处理后端错误
+          const errorMsg = resp?.message || '获取openid失败'
+          console.error('Backend error:', errorMsg) // 调试输出:后端错误
+          uni.showToast({ title: errorMsg, icon: 'none' })
+        }
+      } catch (err) {
+        console.error('Request failed:', err) // 调试输出:请求失败
+        uni.showToast({ title: '网络请求失败,请重试', icon: 'none' })
+      }
+    },
+    fail: (err) => {
+      console.error('uni.login failed:', err) // 调试输出:登录失败
+      uni.showToast({ title: '登录失败,请重试', icon: 'none' })
+    }
+  })
+}
+</script>
+
+<style>
+.login-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;
+}
+
+/* 让卡片在可用空间内垂直居中(当内容较短时) */
+.spacer {
+  width: 100%;
+}
+
+.spacer.top {
+  flex: 1;
+}
+
+.spacer.bottom {
+  flex: 2;
+}
+
+.login-card {
+  /* remove auto margins; vertical spacing controlled by spacers */
+  background: #fff;
+  border-radius: 20rpx;
+  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
+  padding: 60rpx 30rpx 120rpx 30rpx;
+  width: 100%;
+  max-width: 600rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.title-section {
+  text-align: center;
+  margin-bottom: 60rpx;
+}
+
+.page-title {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+}
+
+.subtitle {
+  font-size: 28rpx;
+  color: #666;
+  display: block;
+}
+
+.section-label {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+  margin-bottom: 20rpx;
+  display: block;
+  text-align: center;
+}
+
+.avatar-area {
+  margin-bottom: 60rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.avatar-wrapper {
+  width: 200rpx;
+  height: 200rpx;
+  border-radius: 200rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
+  border: 4rpx solid #07C160;
+  background: #fff;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 100%;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar-hint {
+  font-size: 24rpx;
+  color: #666;
+  margin-top: 20rpx;
+  text-align: center;
+}
+
+.input-section {
+  width: 100%;
+  margin-bottom: 60rpx;
+}
+
+.nickname-input {
+  width: 100%;
+  text-align: center;
+  height: 100rpx;
+  border: 2rpx solid #ddd;
+  border-radius: 12rpx;
+  font-size: 32rpx;
+  background: #f9f9f9;
+  color: #333;
+}
+
+.login-btn {
+  width: 100%;
+  height: 100rpx;
+  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
+  color: #fff;
+  border-radius: 12rpx;
+  font-size: 36rpx;
+  font-weight: bold;
+  text-align: center;
+  line-height: 100rpx;
+  border: none;
+  box-shadow: 0 4rpx 12rpx rgba(7, 193, 96, 0.3);
+}
+
+.login-btn:active {
+  background: linear-gradient(135deg, #00A854 0%, #07C160 100%);
+  transform: scale(0.98);
+}
+</style>

+ 84 - 242
src/pages/login/login.vue

@@ -2,142 +2,84 @@
   <CustomNav title="登录" leftType="home" />
   <view class="login-container">
     <view class="spacer top" />
+
     <view class="login-card">
       <view class="title-section">
         <text class="page-title">欢迎使用慢病管理APP</text>
-        </view>
-
-      <view class="avatar-area">
-        <text class="section-label">请点击下方选择您的头像</text>
-        <!-- only available in Weixin Mini Program: chooseAvatar open-type -->
-        <!-- 使用 tap 事件在打开前设置锁,防止重复调用导致 chooseAvatar:fail 错误 -->
-        <button
-          class="avatar-wrapper"
-          :class="{ disabled: isChoosing }"
-          :disabled="isChoosing"
-          open-type="chooseAvatar"
-          @tap="startChooseAvatar"
-          @chooseavatar="onChooseAvatar"
-        >
-          <view class="avatar-frame">
-            <image class="avatar-img" :src="avatarUrl" mode="aspectFill" />
-          </view>
-        </button>
-        </view>
+        <text class="subtitle">请选择您的身份进行登录</text>
+      </view>
 
-      <form @submit.prevent="onSubmit">
-        <view class="input-section">
-          <text class="section-label">请输入您的名字</text>
-          <input class="nickname-input" type="nickname" placeholder="请点击此处进行输入" v-model="nickname" @blur="onNicknameBlur" />
-        </view>
+      <view class="role-section">
+        <button class="role-btn" @click="onSelectRole(2)" :disabled="isLogging">患者</button>
+        <button class="role-btn" @click="onSelectRole(3)" :disabled="isLogging">患者家属</button>
+        <button class="role-btn" @click="onSelectRole(1)" :disabled="isLogging">医生</button>
+      </view>
 
-        <button form-type="submit" class="login-btn">提交</button>
-      </form>
+      <view class="info-text">点击身份后会使用微信登录并向后端发送登录请求。控制台将打印后端返回的 token。</view>
     </view>
+
     <view class="spacer bottom" />
   </view>
 </template>
 
 <script setup lang="ts">
 import { ref } from 'vue'
-import { onMounted } from 'vue'
-import PageTitle from '@/components/PageTitle.vue'
 import CustomNav from '@/components/CustomNav.vue'
 
-// 页面标题,用于导航栏显示
-const title = ref('登录')
+const isLogging = ref(false)
 
-// 默认头像URL,用于未选择头像时的显示
-const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
-const avatarUrl = ref(defaultAvatarUrl)
-// 防止重复触发 chooseAvatar 的并发锁,避免多次调用导致错误
-const isChoosing = ref(false)
-const nickname = ref('')
+/**
+ * role: 1=SYS_ADMIN(医生), 2=PATIENT(患者), 3=PATIENT_FAMILY(患者家属)
+ */
+async function onSelectRole(role: number) {
+  if (isLogging.value) return
+  isLogging.value = true
+  console.log('Selected role:', role)
 
-// 处理chooseAvatar事件回调(微信小程序)
-// 此函数在用户选择头像后被调用,用于更新头像URL和可能的昵称
-function onChooseAvatar(e: any) {
-  console.log('onChooseAvatar called with event:', e) // 调试输出:记录事件对象
-  // 在微信小程序中,e.detail 可能包含 avatarUrl(字符串)或更复杂结构
   try {
-    const detail = e?.detail
-    console.log('Event detail:', detail) // 调试输出:检查detail内容
-    let url = ''
-    if (!detail) {
-      // 兼容某些平台直接传回字符串
-      url = typeof e === 'string' ? e : ''
-      console.log('No detail, using event as string:', url) // 调试输出:无detail时的处理
-    } else if (typeof detail === 'string') {
-      url = detail
-      console.log('Detail is string:', url) // 调试输出:detail为字符串
-    } else if (detail.avatarUrl) {
-      url = detail.avatarUrl
-      console.log('Using detail.avatarUrl:', url) // 调试输出:使用avatarUrl字段
-    } else if (Array.isArray(detail) && detail[0]) {
-      url = detail[0]
-      console.log('Using first element of array:', url) // 调试输出:数组第一个元素
-    }
-    if (url) {
-      avatarUrl.value = url
-      console.log('Avatar URL updated to:', avatarUrl.value) // 调试输出:头像URL更新
-    }
-    // 如果 detail 中包含昵称(某些平台可能提供),尝试填充
-    if (detail && detail.nickName && !nickname.value) {
-      nickname.value = detail.nickName
-      console.log('Nickname updated from detail:', nickname.value) // 调试输出:昵称更新
+    uni.showLoading({ title: '登录中...' })
+    const loginRes = await new Promise<any>((resolve, reject) => {
+      uni.login({
+        provider: 'weixin',
+        success: resolve,
+        fail: reject
+      })
+    })
+    console.log('uni.login success:', loginRes)
+    const code = loginRes.code
+    const response = await uni.request({
+      url: 'http://127.0.0.1:8080/get_openid',
+      method: 'POST',
+      header: { 'Content-Type': 'application/json' },
+      data: { code: code, role: role }
+    })
+    console.log('Backend response:', response)
+    // 兼容后端返回格式,提取 token 并打印
+    const resp = response.data as any
+    let token: string | undefined
+    if (resp && resp.code === 200 && resp.data && resp.data.token) {
+      token = resp.data.token
+    } else if (resp && resp.token) {
+      token = resp.token
+    } else {
+      throw new Error('Unexpected backend response shape')
     }
+    console.log('Token from backend:', token)
+    // 保存 token 和 role 到本地存储
+    uni.setStorageSync('token', token)
+    uni.setStorageSync('role', role)
+    uni.hideLoading()
+    uni.showToast({ title: '登录成功', icon: 'success' })
+    // 跳转到首页
+    setTimeout(() => {
+      uni.switchTab({ url: '/pages/index/index' })
+    }, 1500)
   } catch (err) {
-    console.error('Error in onChooseAvatar:', err) // 调试输出:捕获错误
-    // ignore
+    console.error('Login error:', err)
+    uni.hideLoading()
+    uni.showToast({ title: '登录失败', icon: 'error' })
   } finally {
-    // 无论成功或失败,都在回调后清理并发锁
-    isChoosing.value = false
-    console.log('isChoosing reset to false') // 调试输出:重置锁
-  }
-}
-
-// 在点击(tap)打开 chooseAvatar 之前设置锁,防止重复打开
-function 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') // 调试输出:设置锁
-  // 为了避免因回调丢失导致一直锁住,设置一个安全超时(3s)作为兜底
-  setTimeout(() => {
-    isChoosing.value = false
-    console.log('Timeout: isChoosing reset to false') // 调试输出:超时重置锁
-  }, 3000)
-}
-
-// 处理昵称输入框失去焦点事件
-function onNicknameBlur() {
-  console.log('onNicknameBlur called, current nickname:', nickname.value) // 调试输出:昵称输入
-  // 微信会在 onBlur 时进行安全检测,若不通过会清空输入,这里仅作默认处理
-}
-
-// 处理表单提交事件
-function onSubmit() {
-  console.log('onSubmit called with avatarUrl:', avatarUrl.value, 'nickname:', nickname.value) // 调试输出:提交数据
-  // 保存用户信息到本地存储(可以改为调用后端或 uni.login + 后端换取 session)
-  const user = {
-    avatar: avatarUrl.value,
-    nickname: nickname.value || '微信用户'
-  }
-  console.log('User object to save:', user) // 调试输出:用户对象
-  uni.setStorageSync('user_info', user)
-
-  // 如果是在小程序端,使用 navigateBack 或 switchTab 返回个人中心
-  // 直接尝试使用 switchTab 返回个人中心(若失败则 navigateTo)
-  try {
-    console.log('Attempting switchTab to profile') // 调试输出:尝试切换标签页
-    uni.switchTab({ url: '/pages/profile/profile' })
-  } catch (e) {
-    console.log('switchTab failed, using navigateTo:', e) // 调试输出:切换失败,使用导航
-    uni.navigateTo({ url: '/pages/profile/profile' })
+    isLogging.value = false
   }
 }
 </script>
@@ -145,12 +87,9 @@ function onSubmit() {
 <style>
 .login-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%);
@@ -159,136 +98,39 @@ function onSubmit() {
   align-items: center;
   justify-content: center;
 }
-
-/* 让卡片在可用空间内垂直居中(当内容较短时) */
-.spacer {
-  width: 100%;
-}
-
-.spacer.top {
-  flex: 1;
-}
-
-.spacer.bottom {
-  flex: 2;
-}
-
+.spacer { width: 100%; }
+.spacer.top { flex: 1 }
+.spacer.bottom { flex: 2 }
 .login-card {
-  /* remove auto margins; vertical spacing controlled by spacers */
   background: #fff;
   border-radius: 20rpx;
-  box-shadow: 0 10rpx 30rpx rgba(0, 0, 0, 0.1);
+  box-shadow: 0 10rpx 30rpx rgba(0,0,0,0.1);
   padding: 60rpx 30rpx 120rpx 30rpx;
   width: 100%;
   max-width: 600rpx;
-  display: flex;
+  display:flex;
   flex-direction: column;
-  align-items: center;
-}
-
-.title-section {
-  text-align: center;
-  margin-bottom: 60rpx;
-}
-
-.page-title {
-  font-size: 48rpx;
-  font-weight: bold;
-  color: #333;
-  margin-bottom: 20rpx;
-  display: block;
-}
-
-.subtitle {
-  font-size: 28rpx;
-  color: #666;
-  display: block;
-}
-
-.section-label {
-  font-size: 32rpx;
-  font-weight: 600;
-  color: #333;
-  margin-bottom: 20rpx;
-  display: block;
-  text-align: center;
-}
-
-.avatar-area {
-  margin-bottom: 60rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-
-.avatar-wrapper {
-  width: 200rpx;
-  height: 200rpx;
-  border-radius: 200rpx;
-  overflow: hidden;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 0;
-  border: 4rpx solid #07C160;
-  background: #fff;
-}
-
-.avatar-frame {
-  width: 100%;
-  height: 100%;
-  border-radius: 100%;
-  overflow: hidden;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.avatar-img {
-  width: 100%;
-  height: 100%;
-  object-fit: cover;
-}
-
-.avatar-hint {
-  font-size: 24rpx;
-  color: #666;
-  margin-top: 20rpx;
-  text-align: center;
-}
-
-.input-section {
-  width: 100%;
-  margin-bottom: 60rpx;
-}
-
-.nickname-input {
-  width: 100%;
-  text-align: center;
-  height: 100rpx;
-  border: 2rpx solid #ddd;
-  border-radius: 12rpx;
-  font-size: 32rpx;
-  background: #f9f9f9;
-  color: #333;
-}
-
-.login-btn {
-  width: 100%;
-  height: 100rpx;
-  background: linear-gradient(135deg, #07C160 0%, #00A854 100%);
-  color: #fff;
-  border-radius: 12rpx;
-  font-size: 36rpx;
-  font-weight: bold;
-  text-align: center;
-  line-height: 100rpx;
-  border: none;
-  box-shadow: 0 4rpx 12rpx rgba(7, 193, 96, 0.3);
+  align-items: stretch; /* 让内部元素(如按钮)可以宽度撑满 */
+}
+.title-section { text-align:center; margin-bottom: 40rpx }
+.page-title { display:block; white-space: normal; font-size:48rpx; font-weight:bold; color:#333; margin-bottom:10rpx; line-height:1.2 }
+.subtitle { display:block; white-space: normal; font-size:28rpx; color:#666; margin-bottom:6rpx }
+.role-section { display:flex; flex-direction: column; gap:20rpx; margin-top:20rpx; width:100% }
+.role-btn {
+  display:inline-flex;
+  justify-content:center;
+  align-items:center;
+  padding: 20rpx 30rpx;
+  background: linear-gradient(135deg,#07C160 0%, #00A854 100%);
+  color:#fff;
+  border-radius:12rpx;
+  font-size:32rpx;
+  border:none;
+  width:100%;
+  box-sizing: border-box;
 }
-
-.login-btn:active {
-  background: linear-gradient(135deg, #00A854 0%, #07C160 100%);
-  transform: scale(0.98);
+.role-btn:disabled {
+  opacity: 0.5;
 }
+.info-text { margin-top:20rpx; font-size:24rpx; color:#666; text-align:center; white-space: normal; word-break: break-word }
 </style>

+ 311 - 0
src/pages/profile/complete-info.vue

@@ -0,0 +1,311 @@
+<template>
+  <CustomNav title="完善信息" leftType="back" />
+  <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.phone" placeholder="请输入手机号" type="number" />
+      </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="form-item">
+        <text class="label">省/市</text>
+        <picker mode="region" :value="region" @change="onRegionChange">
+          <view class="picker">{{ region.join(' ') || '请选择省/市' }}</view>
+        </picker>
+      </view>
+
+      <view class="form-item">
+        <text class="label">电话号码</text>
+        <input class="input" v-model="form.phone2" placeholder="请输入电话号码" type="number" />
+      </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 CustomNav from '@/components/CustomNav.vue'
+
+const form = ref({
+  avatar: '',
+  nickname: '',
+  phone: '',
+  age: '',
+  sex: 0,
+  address: '',
+  phone2: ''
+})
+
+const region = ref<string[]>([])
+const submitting = ref(false)
+const isChoosing = ref(false)
+
+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 onRegionChange = (e: any) => {
+  region.value = e.detail.value
+  form.value.address = region.value.join(' ')
+}
+
+const onSubmit = async () => {
+  if (submitting.value) return
+  if (!form.value.nickname || !form.value.avatar) {
+    uni.showToast({ title: '请填写昵称和选择头像', icon: 'none' })
+    return
+  }
+  submitting.value = true
+  try {
+    const token = uni.getStorageSync('token')
+    const response = await uni.request({
+      url: 'http://127.0.0.1:8080/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.navigateBack()
+      }, 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;
+  background-color: #f5f5f5;
+  padding-top: calc(var(--status-bar-height) + 44px);
+  padding-left: 40rpx;
+  padding-right: 40rpx;
+  padding-bottom: 40rpx;
+}
+
+.form-section {
+  background-color: #fff;
+  border-radius: 20rpx;
+  padding: 40rpx;
+}
+
+.avatar-section {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+
+.avatar {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 60rpx;
+  background-color: #eee;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-wrapper {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 60rpx;
+  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;
+}
+
+.radio-group {
+  display: flex;
+  gap: 40rpx;
+}
+
+.radio-item {
+  display: flex;
+  align-items: center;
+  font-size: 32rpx;
+  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: #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>

+ 37 - 5
src/pages/profile/profile.vue

@@ -10,7 +10,7 @@
         </view>
         <view class="user-info">
           <text class="username">{{ user.nickname || '慢病患者' }}</text>
-          <text class="user-id" v-if="user.nickname">ID: 123456</text>
+          <text class="user-id" v-if="user.openid">ID: {{ user.openid }}</text>
         </view>
       </view>
     </view>
@@ -49,7 +49,7 @@ import TabBar from '@/components/TabBar.vue'
 
 const title = ref('个人中心')
 
-const user = ref<{ avatar?: string; nickname?: string }>({})
+const user = ref<{ avatar?: string; nickname?: string; openid?: string }>({})
 
 const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
 
@@ -82,12 +82,43 @@ const loadUser = () => {
   }
 }
 
+const fetchUserInfo = async () => {
+  try {
+    const token = uni.getStorageSync('token')
+    if (!token) return
+    const response = await uni.request({
+      url: 'http://127.0.0.1:8080/user_info',
+      method: 'POST',
+      header: {
+        'Content-Type': 'application/json',
+        'Authorization': `Bearer ${token}`
+      },
+      data: {}
+    })
+    console.log('User info response:', response)
+    const resp = response.data as any
+    if (resp && resp.code === 200 && resp.data) {
+      user.value = resp.data
+      // 保存到本地存储
+      uni.setStorageSync('user_info', resp.data)
+      // 检查 nickname 和 avatar
+      if (!resp.data.nickname || !resp.data.avatar) {
+        uni.navigateTo({ url: '/pages/profile/complete-info' })
+      }
+    }
+  } catch (err) {
+    console.error('Fetch user info error:', err)
+  }
+}
+
 // 如果在微信小程序端且未登录,自动跳转到登录页
 onShow(() => {
-  loadUser()
-  if (!user.value?.nickname) {
+  const token = uni.getStorageSync('token')
+  if (!token) {
     // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
     uni.reLaunch({ url: '/pages/login/login' })
+  } else {
+    fetchUserInfo()
   }
 })
 
@@ -105,7 +136,8 @@ const onLogout = () => {
     content: '确定要退出登录吗?',
     success: (res) => {
       if (res.confirm) {
-        uni.removeStorageSync('user_info')
+        uni.removeStorageSync('token')
+        uni.removeStorageSync('role')
         user.value = {}
         uni.showToast({ title: '已退出登录', icon: 'none' })
       }

BIN
src/static/logo.png


+ 20 - 0
慢病设计.txt

@@ -2,6 +2,26 @@
 首先,本小程序是一个学习研究的小程序,服务于真实的医疗机构对于慢病患者服务管理的需求。
 主要是方便患者上传自测数据,方便医生查看,方便医生进行管理。
 
+登录流程
+首先用户进入微信小程序后,点击个人,如果没有登录,则会进入登录引导页面。
+在引导页面,用户需要选择身份类型,有3种身份,分别是医生、患者、患者家属,
+  点击后则触发wx.login()方法,将wx.login的一次性验证码并根据不同的选项向后端发送登录请求
+  (
+@Data
+public class GetOpenidRequest {
+    private String code;
+    /** PermissionGroup code: 1=SYS_ADMIN,2=DOCTOR,3=PATIENT,4=PATIENT_FAMILY */
+    private Integer role;
+}
+),后端会返回{"code":200,"message":"ok","data":{"token":"af6dfb3e70fa43548c38e7d55431b105"}}
+请保存好token,并保存在手机本地。
+然后再次访问
+  
+
+
+
+
+
 首先分为三大核心功能区
 1.慢病首页
 2.健康管理