Ver código fonte

feat(patient): 添加骨架加载视图,优化用户信息和新闻列表加载体验

mcbaiyun 2 semanas atrás
pai
commit
bf7bc22f08
2 arquivos alterados com 233 adições e 27 exclusões
  1. 117 14
      src/pages/patient-family/index/index.vue
  2. 116 13
      src/pages/patient/index/index.vue

+ 117 - 14
src/pages/patient-family/index/index.vue

@@ -6,12 +6,23 @@
         <view class="avatar-section">
           <view class="avatar">
             <view class="avatar-frame">
-              <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+                    <template v-if="isLoadingUser">
+                      <view class="skeleton skeleton-circle avatar-skel" />
+                    </template>
+                    <template v-else>
+                      <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+                    </template>
             </view>
           </view>
           <view class="user-details">
-            <text class="username">{{ user.nickname || '未登录' }}</text>
-            <text class="user-role">家属</text>
+            <template v-if="isLoadingUser">
+              <view class="skeleton skeleton-text username-skel" />
+              <view class="skeleton skeleton-text small-skel" style="width:80rpx;margin-top:10rpx" />
+            </template>
+            <template v-else>
+              <text class="username">{{ user.nickname || '未登录' }}</text>
+              <text class="user-role">家属</text>
+            </template>
           </view>
           <view class="message-button" @click="onMessageClick">
             <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
@@ -52,18 +63,36 @@
           <text class="card-title">健康资讯</text>
         </view>
         <view class="card-content">
-          <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
-            <view v-if="news.coverImage" class="news-image-container">
-              <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
-            </view>
-            <view v-else class="news-placeholder">
-              <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+          <template v-if="isLoadingNews">
+            <view class="news-item" v-for="i in 3" :key="`skel-${i}`">
+              <view class="news-image-container">
+                <view class="skeleton skeleton-rect news-image-skel" />
+              </view>
+              <view class="news-text">
+                <view class="skeleton skeleton-text" style="width:70%" />
+                <view class="skeleton skeleton-text" style="width:90%;margin-top:10rpx;height:20rpx" />
+              </view>
             </view>
-            <view class="news-text">
-              <text class="news-title">{{ news.title }}</text>
-              <text class="news-desc">{{ news.summary }}</text>
+          </template>
+          <template v-else>
+            <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
+              <view v-if="getImageSrc(news.coverImage) !== defaultImage" class="news-image-container">
+                <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
+              </view>
+              <view v-else class="news-placeholder">
+                <view v-if="isProtectedUrl(resolveImageUrl(news.coverImage)) && !downloadedNewsImages[resolveImageUrl(news.coverImage)]" class="news-image-container">
+                  <view class="skeleton skeleton-rect news-image-skel" />
+                </view>
+                <view v-else class="news-placeholder">
+                  <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+                </view>
+              </view>
+              <view class="news-text">
+                <text class="news-title">{{ news.title }}</text>
+                <text class="news-desc">{{ news.summary }}</text>
+              </view>
             </view>
-          </view>
+          </template>
         </view>
       </view>
 
@@ -73,7 +102,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, watch, onUnmounted } from 'vue'
+import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
 import { onShow, onHide } from '@dcloudio/uni-app'
 import CustomNav from '@/components/custom-nav.vue'
 import { fetchUserInfo as fetchUserInfoApi } from '@/api/user'
@@ -88,6 +117,10 @@ import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQ
 
 const user = ref<{ avatar?: string; nickname?: string }>({})
 
+// 加载状态:用于显示骨架屏
+const isLoadingUser = ref(true)
+const isLoadingNews = ref(true)
+
 // 未读消息数量
 const unreadMessageCount = ref(0)
 
@@ -161,6 +194,8 @@ const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
     if (!token) return
+    // 开始加载用户信息骨架
+    isLoadingUser.value = true
     uni.showLoading({ title: '加载中...' })
     const response = await fetchUserInfoApi()
     uni.hideLoading()
@@ -210,6 +245,8 @@ const fetchUserInfo = async () => {
   } catch (err) {
     uni.hideLoading()
     console.error('Fetch user info error:', err)
+  } finally {
+    isLoadingUser.value = false
   }
 }
 
@@ -470,19 +507,36 @@ onUnmounted(() => {
 })
 
 const fetchNews = async () => {
+  isLoadingNews.value = true
   try {
     const result: any = await getNewsList({ page: 1, size: 5 })
     if (result?.data?.code === 200) {
       newsList.value = result.data.data.records || []
+      isLoadingNews.value = false
     } else {
       uni.showToast({ title: '获取资讯失败', icon: 'none' })
+      isLoadingNews.value = false
     }
   } catch (err) {
     console.error('fetch news error', err)
     uni.showToast({ title: '获取资讯失败', icon: 'none' })
+    isLoadingNews.value = false
   }
 }
 
+onMounted(() => {
+  // 预读取本地 user_info,加快首屏显示
+  try {
+    const u = uni.getStorageSync('user_info')
+    if (u) {
+      user.value = u
+      isLoadingUser.value = false
+    }
+  } catch (e) {
+    // ignore
+  }
+})
+
 const onNewsClick = (news: News) => {
   uni.navigateTo({
     url: `/pages/public/news-detail?id=${news.id}`
@@ -891,4 +945,53 @@ const onNewsClick = (news: News) => {
   color: #666;
   line-height: 1.4;
 }
+
+/* Skeleton styles (与其他首页保持一致) */
+.skeleton {
+  position: relative;
+  overflow: hidden;
+  background: #eee;
+}
+.skeleton::after {
+  content: '';
+  position: absolute;
+  left: -150%;
+  top: 0;
+  height: 100%;
+  width: 150%;
+  background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.6), rgba(255,255,255,0));
+  animation: shimmer 1.2s infinite;
+}
+.skeleton-circle {
+  border-radius: 50%;
+}
+.skeleton-text {
+  height: 30rpx;
+  border-radius: 6rpx;
+  width: 140rpx;
+}
+.skeleton-rect {
+  height: 100%;
+  border-radius: 8rpx;
+}
+.avatar-skel {
+  width: 100%;
+  height: 100%;
+}
+.username-skel {
+  width: 220rpx;
+  height: 36rpx;
+}
+.small-skel {
+  height: 26rpx;
+}
+.news-image-skel {
+  width: 100%;
+  height: 100%;
+}
+
+@keyframes shimmer {
+  0% { transform: translateX(0%); }
+  100% { transform: translateX(100%); }
+}
 </style>

+ 116 - 13
src/pages/patient/index/index.vue

@@ -6,12 +6,23 @@
         <view class="avatar-section">
           <view class="avatar">
             <view class="avatar-frame">
-              <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+                <template v-if="isLoadingUser">
+                  <view class="skeleton skeleton-circle avatar-skel" />
+                </template>
+                <template v-else>
+                  <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
+                </template>
             </view>
           </view>
           <view class="user-details">
-            <text class="username">{{ user.nickname || '未登录' }}</text>
-            <text class="user-age" v-if="user.age">年龄: {{ user.age }}</text>
+            <template v-if="isLoadingUser">
+              <view class="skeleton skeleton-text username-skel" />
+              <view class="skeleton skeleton-text small-skel" style="width:80rpx;margin-top:10rpx" />
+            </template>
+            <template v-else>
+              <text class="username">{{ user.nickname || '未登录' }}</text>
+              <text class="user-age" v-if="user.age">年龄: {{ user.age }}</text>
+            </template>
           </view>
           <view class="message-button" @click="onMessageClick">
             <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
@@ -73,18 +84,38 @@
           <text class="card-title">健康资讯</text>
         </view>
         <view class="card-content">
-          <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
-            <view v-if="news.coverImage" class="news-image-container">
-              <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
-            </view>
-            <view v-else class="news-placeholder">
-              <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+          <!-- loading skeletons for news list -->
+          <template v-if="isLoadingNews">
+            <view class="news-item" v-for="i in 3" :key="`skel-${i}`">
+              <view class="news-image-container">
+                <view class="skeleton skeleton-rect news-image-skel" />
+              </view>
+              <view class="news-text">
+                <view class="skeleton skeleton-text" style="width:70%" />
+                <view class="skeleton skeleton-text" style="width:90%;margin-top:10rpx;height:20rpx" />
+              </view>
             </view>
-            <view class="news-text">
-              <text class="news-title">{{ news.title }}</text>
-              <text class="news-desc">{{ news.summary }}</text>
+          </template>
+          <template v-else>
+            <view class="news-item" v-for="(news, index) in newsList" :key="index" @click="onNewsClick(news)">
+              <view v-if="getImageSrc(news.coverImage) !== defaultImage" class="news-image-container">
+                <image class="news-image" :src="getImageSrc(news.coverImage)" mode="aspectFill" />
+              </view>
+              <view v-else class="news-placeholder">
+                <!-- 如果图片是受保护且还未下载,显示骨架而不是静态占位图 -->
+                <view v-if="isProtectedUrl(resolveImageUrl(news.coverImage)) && !downloadedNewsImages[resolveImageUrl(news.coverImage)]" class="news-image-container">
+                  <view class="skeleton skeleton-rect news-image-skel" />
+                </view>
+                <view v-else class="news-placeholder">
+                  <image class="placeholder-icon" src="/static/icons/remixicon/image-line.svg" />
+                </view>
+              </view>
+              <view class="news-text">
+                <text class="news-title">{{ news.title }}</text>
+                <text class="news-desc">{{ news.summary }}</text>
+              </view>
             </view>
-          </view>
+          </template>
         </view>
       </view>
 
@@ -127,6 +158,10 @@ import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQ
 
 const user = ref<{ avatar?: string; nickname?: string; age?: number }>({})
 
+// 加载状态:用于显示骨架屏
+const isLoadingUser = ref(true)
+const isLoadingNews = ref(true)
+
 // 未读消息数量
 const unreadMessageCount = ref(0)
 
@@ -223,16 +258,20 @@ const loadUser = () => {
 }
 
 const fetchNews = async () => {
+  isLoadingNews.value = true
   try {
     const result: any = await getNewsList({ page: 1, size: 5 })
     if (result?.data?.code === 200) {
       newsList.value = result.data.data.records || []
+      // 加载完成
+      isLoadingNews.value = false
     } else {
       uni.showToast({ title: '获取资讯失败', icon: 'none' })
     }
   } catch (err) {
     console.error('fetch news error', err)
     uni.showToast({ title: '获取资讯失败', icon: 'none' })
+    isLoadingNews.value = false
   }
 }
 
@@ -327,6 +366,8 @@ const fetchUserInfo = async () => {
   try {
     const token = uni.getStorageSync('token')
     if (!token) return
+    // 用户信息开始加载
+    isLoadingUser.value = true
     uni.showLoading({ title: '加载中...' })
     const response = await fetchUserInfoApi()
     uni.hideLoading()
@@ -371,6 +412,9 @@ const fetchUserInfo = async () => {
   } catch (err) {
     uni.hideLoading()
     console.error('Fetch user info error:', err)
+  } finally {
+    // 无论成功失败,都把加载态关闭(确保骨架隐藏)
+    isLoadingUser.value = false
   }
 }
 
@@ -532,6 +576,16 @@ onMounted(() => {
       // generateCheckinTasks()
     }, 100)
   })
+  // 尝试读取本地缓存的用户信息以加快首屏显示(若存在则关闭用户骨架)
+  try {
+    const u = uni.getStorageSync('user_info')
+    if (u) {
+      user.value = u
+      isLoadingUser.value = false
+    }
+  } catch (e) {
+    // ignore
+  }
 })
 
 // 页面隐藏时设置不活跃
@@ -946,4 +1000,53 @@ const onNewsClick = (news: News) => {
   color: #666;
   line-height: 1.4;
 }
+
+/* Skeleton styles */
+.skeleton {
+  position: relative;
+  overflow: hidden;
+  background: #eee;
+}
+.skeleton::after {
+  content: '';
+  position: absolute;
+  left: -150%;
+  top: 0;
+  height: 100%;
+  width: 150%;
+  background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,0.6), rgba(255,255,255,0));
+  animation: shimmer 1.2s infinite;
+}
+.skeleton-circle {
+  border-radius: 50%;
+}
+.skeleton-text {
+  height: 30rpx;
+  border-radius: 6rpx;
+  width: 140rpx;
+}
+.skeleton-rect {
+  height: 100%;
+  border-radius: 8rpx;
+}
+.avatar-skel {
+  width: 100%;
+  height: 100%;
+}
+.username-skel {
+  width: 220rpx;
+  height: 36rpx;
+}
+.small-skel {
+  height: 26rpx;
+}
+.news-image-skel {
+  width: 100%;
+  height: 100%;
+}
+
+@keyframes shimmer {
+  0% { transform: translateX(0%); }
+  100% { transform: translateX(100%); }
+}
 </style>