Преглед изворни кода

新增页面组件:CustomNav、PageTitle、TabBar,更新健康数据、登录和个人中心页面结构

mcbaiyun пре 3 месеци
родитељ
комит
d7ec48d50a

+ 0 - 0
src/components/CustomNav.vue


+ 59 - 0
src/components/PageTitle.vue

@@ -0,0 +1,59 @@
+<template>
+  <view>
+    <view v-if="useCustomNav" class="custom-nav">
+      <view class="nav-title">{{ title }}</view>
+    </view>
+    <view class="text-area">
+      <text class="title">{{ title }}</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { defineProps } from 'vue'
+
+const props = defineProps({
+  title: { type: String, required: true },
+  useCustomNav: { type: Boolean, default: false }
+})
+
+const title = props.title
+const useCustomNav = props.useCustomNav
+</script>
+
+<style scoped>
+.text-area {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  margin-top: 40rpx;
+}
+
+.title {
+  font-size: 36rpx;
+  color: #8f8f94;
+  font-weight: 400;
+}
+
+.custom-nav {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  height: 88rpx;
+  padding-top: constant(safe-area-inset-top);
+  padding-top: env(safe-area-inset-top);
+  background-color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+  border-bottom: 1rpx solid #eee;
+}
+
+.nav-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #333;
+}
+</style>

+ 67 - 0
src/components/TabBar.vue

@@ -0,0 +1,67 @@
+<template>
+  <view class="tab-bar">
+    <view class="tab-item" @click="onTabClick(0)">
+      <text class="tab-text">慢病首页</text>
+    </view>
+    <view class="tab-item" @click="onTabClick(1)">
+      <text class="tab-text">健康数据</text>
+    </view>
+    <view class="tab-item" @click="onTabClick(2)">
+      <text class="tab-text">个人中心</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+const onTabClick = (index: number) => {
+  console.log('Tab clicked:', index)
+
+  switch (index) {
+    case 0: // 慢病首页
+      uni.switchTab({
+        url: '/pages/index/index'
+      })
+      break
+    case 1: // 健康数据
+      uni.showToast({
+        title: '健康数据功能开发中',
+        icon: 'none'
+      })
+      break
+    case 2: // 个人中心
+      uni.switchTab({
+        url: '/pages/profile/profile'
+      })
+      break
+  }
+}
+</script>
+
+<style>
+.tab-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 0;
+  height: 100rpx;
+  background-color: #fff;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+  border-top: 1rpx solid #eee;
+}
+
+.tab-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  height: 100%;
+}
+
+.tab-text {
+  font-size: 24rpx;
+  color: #666;
+}
+</style>

+ 32 - 0
src/pages/health/health.vue

@@ -0,0 +1,32 @@
+<template>
+  <view class="content">
+    <PageTitle :title="title" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import PageTitle from '@/components/PageTitle.vue'
+
+const title = ref('健康数据')
+</script>
+
+<style>
+.content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding-bottom: 100rpx; /* 给底边栏留空间 */
+}
+
+.text-area {
+  display: flex;
+  justify-content: center;
+}
+
+.title {
+  font-size: 36rpx;
+  color: #8f8f94;
+}
+</style>

+ 4 - 3
src/pages/index/index.vue

@@ -1,14 +1,14 @@
 <template>
   <view class="content">
     <image class="logo" src="/static/logo.png" />
-    <view class="text-area">
-      <text class="title">{{ title }}</text>
-    </view>
+    <PageTitle :title="title" />
   </view>
 </template>
 
 <script setup lang="ts">
 import { ref } from 'vue'
+import PageTitle from '@/components/PageTitle.vue'
+
 const title = ref('Hello')
 </script>
 
@@ -18,6 +18,7 @@ const title = ref('Hello')
   flex-direction: column;
   align-items: center;
   justify-content: center;
+  padding-bottom: 100rpx; /* 给底边栏留空间 */
 }
 
 .logo {

+ 166 - 0
src/pages/login/login.vue

@@ -0,0 +1,166 @@
+<template>
+  <view class="login-container">
+    <view class="avatar-area">
+      <!-- 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">
+      <input class="nickname-input" type="nickname" placeholder="请输入昵称" v-model="nickname" @blur="onNicknameBlur" />
+
+      <button form-type="submit" class="login-btn">使用微信登录并完善信息</button>
+    </form>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { onMounted } from 'vue'
+import PageTitle from '@/components/PageTitle.vue'
+
+const title = ref('登录')
+
+const defaultAvatarUrl = 'https://mmbiz.qpic.cn/mmbiz/icTdbqWNOwNRna42FI242Lcia07jQodd2FJGIYQfG0LAJGFxM4FbnQP6yfMxBgJ0F3YRqJCJ1aPAK2dQagdusBZg/0'
+const avatarUrl = ref(defaultAvatarUrl)
+// 防止重复触发 chooseAvatar 的并发锁
+const isChoosing = ref(false)
+const nickname = ref('')
+
+// 处理chooseAvatar事件回调(微信小程序)
+function onChooseAvatar(e: any) {
+  // 在微信小程序中,e.detail 可能包含 avatarUrl(字符串)或更复杂结构
+  try {
+    const detail = e?.detail
+    let url = ''
+    if (!detail) {
+      // 兼容某些平台直接传回字符串
+      url = typeof e === 'string' ? e : ''
+    } else if (typeof detail === 'string') {
+      url = detail
+    } else if (detail.avatarUrl) {
+      url = detail.avatarUrl
+    } else if (Array.isArray(detail) && detail[0]) {
+      url = detail[0]
+    }
+    if (url) {
+      avatarUrl.value = url
+    }
+    // 如果 detail 中包含昵称(某些平台可能提供),尝试填充
+    if (detail && detail.nickName && !nickname.value) {
+      nickname.value = detail.nickName
+    }
+  } catch (err) {
+    // ignore
+  } finally {
+    // 无论成功或失败,都在回调后清理并发锁
+    isChoosing.value = false
+  }
+}
+
+// 在点击(tap)打开 chooseAvatar 之前设置锁,防止重复打开
+function startChooseAvatar() {
+  if (isChoosing.value) {
+    // 如果已经在打开中,直接忽略后续点击
+    return
+  }
+  isChoosing.value = true
+  // 为了避免因回调丢失导致一直锁住,设置一个安全超时(3s)作为兜底
+  setTimeout(() => {
+    isChoosing.value = false
+  }, 3000)
+}
+
+function onNicknameBlur() {
+  // 微信会在 onBlur 时进行安全检测,若不通过会清空输入,这里仅作默认处理
+}
+
+function onSubmit() {
+  // 保存用户信息到本地存储(可以改为调用后端或 uni.login + 后端换取 session)
+  const user = {
+    avatar: avatarUrl.value,
+    nickname: nickname.value || '微信用户'
+  }
+  uni.setStorageSync('user_info', user)
+
+  // 如果是在小程序端,使用 navigateBack 或 switchTab 返回个人中心
+  // 直接尝试使用 switchTab 返回个人中心(若失败则 navigateTo)
+  try {
+    uni.switchTab({ url: '/pages/profile/profile' })
+  } catch (e) {
+    uni.navigateTo({ url: '/pages/profile/profile' })
+  }
+}
+</script>
+
+<style>
+.login-container {
+  padding: 40rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.avatar-area {
+  margin-top: 80rpx;
+  margin-bottom: 40rpx;
+}
+
+.avatar-wrapper {
+  width: 180rpx;
+  height: 180rpx;
+  border-radius: 180rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding-left: 0px;
+  padding-right: 0px;
+}
+
+.avatar-frame {
+  width: 100%;
+  height: 100%;
+  border-radius: 90rpx;
+  overflow: hidden;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.nickname-input {
+  width: 100%;
+  height: 80rpx;
+  border: 1rpx solid #ddd;
+  border-radius: 8rpx;
+  padding: 0 20rpx;
+  margin-bottom: 30rpx;
+}
+
+.login-btn {
+  width: 100%;
+  height: 80rpx;
+  background-color: #007aff;
+  color: #fff;
+  border-radius: 8rpx;
+  line-height: 80rpx;
+  text-align: center;
+}
+</style>

+ 242 - 0
src/pages/profile/profile.vue

@@ -0,0 +1,242 @@
+<template>
+  <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.nickname">ID: 123456</text>
+        </view>
+      </view>
+    </view>
+
+
+
+    <view class="menu-list">
+      <view class="menu-item" @click="onMenuClick('health')">
+        <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>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { computed } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import PageTitle from '@/components/PageTitle.vue'
+
+const title = ref('个人中心')
+
+const user = ref<{ avatar?: string; nickname?: 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
+  }
+}
+
+// 如果在微信小程序端且未登录,自动跳转到登录页
+onShow(() => {
+  loadUser()
+  if (!user.value?.nickname) {
+    // 使用 uni.reLaunch 替代 navigateTo,确保页面栈被清空
+    uni.reLaunch({ url: '/pages/login/login' })
+  }
+})
+
+const onMenuClick = (type: string) => {
+  console.log('Menu clicked:', type)
+  uni.showToast({
+    title: `${type} 功能开发中`,
+    icon: 'none'
+  })
+}
+
+const onLogout = () => {
+  uni.showModal({
+    title: '提示',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        uni.removeStorageSync('user_info')
+        user.value = {}
+        uni.showToast({ title: '已退出登录', icon: 'none' })
+      }
+    }
+  })
+}
+</script>
+
+<style>
+.profile-container {
+  min-height: 100vh;
+  background-color: #f5f5f5;
+  padding-bottom: 100rpx; /* 给底边栏留空间 */
+}
+
+.header {
+  background-color: #fff;
+  padding: 40rpx;
+  margin-bottom: 20rpx;
+}
+
+.avatar-section {
+  display: flex;
+  align-items: center;
+}
+
+.avatar {
+  width: 120rpx;
+  height: 120rpx;
+  border-radius: 60rpx;
+  background-color: #007aff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 30rpx;
+}
+
+.custom-nav {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  height: 88rpx;
+  padding-top: constant(safe-area-inset-top);
+  padding-top: env(safe-area-inset-top);
+  background-color: #fff;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+  border-bottom: 1rpx solid #eee;
+}
+
+.nav-title {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #333;
+}
+
+.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-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>