Kaynağa Gözat

feat(skeleton): 添加用户信息和患者动态的骨架屏,优化加载体验

mcbaiyun 2 hafta önce
ebeveyn
işleme
5b5b4259d1

+ 161 - 1
docs/uni-app 页面加载提示对比分析.md → docs/uni-app 页面加载提示对比分析(showLoading vs 骨架动画).md

@@ -185,9 +185,169 @@ const fetchMessages = async () => {
 - 骨架屏和实际内容应使用互斥的条件渲染,避免同时显示
 - 在页面重新加载(如 `onShow` 触发)时,确保旧数据被正确隐藏
 - 参考 `message-detail.vue` 的实现模式,确保加载状态管理的完整性
+- 骨架屏设计时应区分动态加载内容和静态资源,避免对静态元素添加不必要的加载动画
+
+### 骨架屏动画效果优化(静态vs动态)
+
+#### 问题现象
+在对比 `news.vue` 和 `message-detail.vue` 的骨架屏实现时,发现 `news.vue` 的骨架屏看起来不够真实、缺乏动态感,而 `message-detail.vue` 的骨架屏更具真实感和流畅性。
+
+#### 问题原因
+`news.vue` 的骨架屏使用静态的渐变背景(`linear-gradient(90deg, #eee, #f6f6f6)`),没有动画效果,只是固定的颜色块;而 `message-detail.vue` 使用了动态的流动动画,通过 CSS `animation` 和 `@keyframes` 让背景从左到右移动,模拟内容正在加载的过程。
+
+#### 解决办法
+为骨架屏元素添加 CSS 动画效果,使其产生流动感,提升真实感。
+
+**具体修改步骤:**
+
+1. 修改骨架元素的背景样式:
+```css
+.skeleton-cover,
+.skeleton-title,
+.skeleton-author,
+.skeleton-summary {
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+}
+```
+
+2. 添加动画定义:
+```css
+@keyframes skeleton-loading {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+```
+
+#### 优化效果
+- 静态骨架屏 → 动态流动动画
+- 提升用户感知的加载真实感
+- 视觉体验更加流畅和专业
+
+#### 经验教训
+- 骨架屏不仅仅是占位符,更应该模拟真实的加载过程
+- 适当的动画效果能显著提升用户体验
+- 参考现有优秀实现(如 `message-detail.vue`),统一项目内的骨架屏风格
+
+### 骨架屏布局真实性优化
+
+#### 问题现象
+在对比 `news.vue` 和 `message-detail.vue` 的骨架屏实现时,发现 `news.vue` 的骨架屏布局与实际内容结构不匹配,看起来不够真实,而 `message-detail.vue` 的骨架屏更贴近实际内容的布局。
+
+#### 问题原因
+`news.vue` 的骨架屏结构是封面占位符 + 标题 + 作者 + 摘要的水平布局,但实际新闻卡片的布局是标题在顶部,然后是图片 + 摘要 + 作者的垂直布局。骨架屏没有准确模拟真实内容的视觉层次和排列方式。
+
+#### 解决办法
+调整骨架屏的 HTML 结构和 CSS 样式,使其完全匹配实际内容的布局。
+
+**具体修改步骤:**
+
+1. 修改骨架屏的 HTML 结构:
+```vue
+<!-- 修改前 -->
+<view class="skeleton-card">
+  <view class="skeleton-cover" />
+  <view class="skeleton-meta">
+    <view class="skeleton-title" />
+    <view class="skeleton-author" />
+    <view class="skeleton-summary" />
+  </view>
+</view>
+
+<!-- 修改后 -->
+<view class="skeleton-card">
+  <view class="skeleton-title" />
+  <view class="skeleton-content">
+    <view class="skeleton-cover" />
+    <view class="skeleton-meta">
+      <view class="skeleton-summary" />
+      <view class="skeleton-author" />
+    </view>
+  </view>
+</view>
+```
+
+2. 调整对应的 CSS 样式:
+```css
+.skeleton-card {
+  background: #fff;
+  border-radius: 12rpx;
+  margin-bottom: 18rpx;
+  overflow: hidden;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
+}
+
+.skeleton-title {
+  width: 60%;
+  height: 34rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  margin: 20rpx;
+  border-radius: 6rpx;
+}
+
+.skeleton-content {
+  display: flex;
+  padding: 20rpx;
+  gap: 20rpx;
+  align-items: center;
+}
+
+.skeleton-cover {
+  width: 220rpx;
+  height: 150rpx;
+  border-radius: 10rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+}
+
+.skeleton-meta {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.skeleton-summary {
+  width: 100%;
+  height: 28rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  margin-bottom: 12rpx;
+  border-radius: 6rpx;
+}
+
+.skeleton-author {
+  width: 50%;
+  height: 24rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  border-radius: 6rpx;
+}
+```
+
+#### 优化效果
+- 骨架屏布局完全匹配实际内容结构
+- 提升用户对加载状态的真实感
+- 改善整体视觉体验
+
+#### 经验教训
+- 骨架屏的设计应该精确模拟真实内容的布局结构
+- 不仅仅是占位符,更要考虑视觉层次和排列方式
+- 定期对比骨架屏与实际内容的差异,确保一致性
 
 ## 相关文件
 - `src/pages/doctor/index/my-patients.vue` - 全局加载提示示例
 - `src/pages/public/message-detail.vue` - 骨架屏加载动画示例
-- `src/pages/doctor/manage/news.vue` - 骨架屏逻辑修复示例</content>
+- `src/pages/doctor/manage/news.vue` - 骨架屏逻辑修复示例
+- `src/pages/doctor/index/index.vue` - 骨架屏逻辑修复示例(用户信息和患者动态)</content>
 <parameter name="filePath">d:\慢病APP\Dev\uniapp-ts\docs\uni-app 页面加载提示对比分析.md

+ 174 - 11
src/pages/doctor/index/index.vue

@@ -3,7 +3,31 @@
   <view class="page-container">
     <view class="content">
       <view class="user-info">
-        <view class="avatar-section">
+        <!-- 用户信息骨架屏 -->
+        <view v-if="userInfoLoading" class="user-info-skeleton">
+          <view class="avatar-section">
+            <view class="avatar">
+              <view class="avatar-frame">
+                <view class="skeleton-avatar-placeholder"></view>
+              </view>
+            </view>
+            <view class="user-details">
+              <view class="skeleton-line skeleton-nickname"></view>
+              <view class="skeleton-line skeleton-title"></view>
+            </view>
+            <view class="message-button" @click="onMessageClick">
+              <image src="/static/icons/remixicon/message-3-line.svg" class="message-icon" />
+              <view class="badge" v-if="unreadMessageCount > 0">{{ unreadMessageCount > 99 ? '99+' : unreadMessageCount }}
+              </view>
+            </view>
+            <view class="qr-button" @click="onQrClick">
+              <image src="/static/icons/remixicon/qr-code-line.svg" class="qr-icon" />
+            </view>
+          </view>
+        </view>
+
+        <!-- 实际用户信息 -->
+        <view v-if="!userInfoLoading" class="avatar-section">
           <view class="avatar">
             <view class="avatar-frame">
               <image class="avatar-img" :src="avatarSrc" mode="aspectFill" />
@@ -100,17 +124,31 @@
           <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 v-if="patientActivitiesLoading" class="skeleton-container">
+            <view class="skeleton-item" v-for="i in 3" :key="i">
+              <view class="skeleton-avatar"></view>
+              <view class="skeleton-text">
+                <view class="skeleton-line skeleton-desc"></view>
+                <view class="skeleton-line skeleton-time"></view>
+              </view>
             </view>
           </view>
-          <view v-if="patientActivities.length === 0" class="no-activity">
-            <text>暂无患者动态</text>
+
+          <!-- 实际内容 -->
+          <view v-if="!patientActivitiesLoading">
+            <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>
@@ -134,6 +172,9 @@ import { getUnreadMessageCount, getMessageList, markMessageAsRead, type MessageQ
 
 const user = ref<{ avatar?: string; nickname?: string; title?: string }>({})
 
+// 用户信息加载状态
+const userInfoLoading = ref(true)
+
 // 未读消息数量
 const unreadMessageCount = ref(0)
 
@@ -170,6 +211,9 @@ const patientActivities = ref<Array<{
   patientAvatar: string
 }>>([])
 
+// 患者动态加载状态
+const patientActivitiesLoading = ref(true)
+
 const loadUser = () => {
   try {
     const u = uni.getStorageSync('user_info')
@@ -183,8 +227,12 @@ const loadUser = () => {
 
 const fetchUserInfo = async () => {
   try {
+    userInfoLoading.value = true
     const token = uni.getStorageSync('token')
-    if (!token) return
+    if (!token) {
+      userInfoLoading.value = false
+      return
+    }
     const response = await fetchUserInfoApi()
     uni.hideLoading()
     console.log('User info response:', response)
@@ -195,6 +243,7 @@ const fetchUserInfo = async () => {
       uni.removeStorageSync('role')
       user.value = {}
       uni.reLaunch({ url: '/pages/public/login/index' })
+      userInfoLoading.value = false
       return
     }
     if (resp && resp.code === 200 && resp.data) {
@@ -237,6 +286,8 @@ const fetchUserInfo = async () => {
   } catch (err) {
     uni.hideLoading()
     console.error('Fetch user info error:', err)
+  } finally {
+    userInfoLoading.value = false
   }
 }
 
@@ -259,9 +310,11 @@ const fetchTodayReminders = async () => {
 
 const fetchPatientActivities = async () => {
   try {
+    patientActivitiesLoading.value = true
     const token = uni.getStorageSync('token')
     if (!token) {
       console.log('No token found, skipping fetchPatientActivities')
+      patientActivitiesLoading.value = false
       return
     }
 
@@ -299,6 +352,8 @@ const fetchPatientActivities = async () => {
     console.error('Fetch patient activities error:', err)
     // 如果接口调用失败,显示空数据
     patientActivities.value = []
+  } finally {
+    patientActivitiesLoading.value = false
   }
 }
 
@@ -856,4 +911,112 @@ const formatActivityDescription = (activity: any) => {
   text-align: center;
   color: #999;
 }
+
+/* 骨架屏样式 */
+.skeleton-container {
+  padding: 20rpx;
+}
+
+.skeleton-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+  padding: 20rpx;
+  border-bottom: 1rpx solid #eee;
+}
+
+.skeleton-item:last-child {
+  margin-bottom: 0;
+  border-bottom: none;
+}
+
+.skeleton-avatar {
+  width: 60rpx;
+  height: 60rpx;
+  border-radius: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  margin-right: 20rpx;
+}
+
+.skeleton-text {
+  flex: 1;
+}
+
+.skeleton-line {
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  border-radius: 6rpx;
+}
+
+.skeleton-desc {
+  width: 70%;
+  height: 32rpx;
+  margin-bottom: 10rpx;
+}
+
+.skeleton-time {
+  width: 40%;
+  height: 28rpx;
+}
+
+/* 用户信息骨架屏样式 */
+.user-info-skeleton .avatar-section {
+  display: flex;
+  align-items: center;
+}
+
+.user-info-skeleton .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;
+}
+
+.skeleton-avatar-placeholder {
+  width: 100%;
+  height: 100%;
+  border-radius: 50%;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+}
+
+.user-info-skeleton .user-details {
+  flex: 1;
+}
+
+.skeleton-nickname {
+  width: 60%;
+  height: 36rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  border-radius: 6rpx;
+  margin-bottom: 10rpx;
+}
+
+.skeleton-title {
+  width: 40%;
+  height: 28rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  border-radius: 6rpx;
+}
+
+@keyframes skeleton-loading {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
 </style>

+ 56 - 29
src/pages/doctor/manage/news.vue

@@ -13,11 +13,13 @@
 
     <view v-if="isLoading" class="skeleton-list">
       <view v-for="i in 3" :key="i" class="skeleton-card">
-        <view class="skeleton-cover" />
-        <view class="skeleton-meta">
-          <view class="skeleton-title" />
-          <view class="skeleton-author" />
-          <view class="skeleton-summary" />
+        <view class="skeleton-title" />
+        <view class="skeleton-content">
+          <view class="skeleton-cover" />
+          <view class="skeleton-meta">
+            <view class="skeleton-summary" />
+            <view class="skeleton-author" />
+          </view>
         </view>
       </view>
     </view>
@@ -480,44 +482,59 @@ onPullDownRefresh(() => {
 }
 
 .skeleton-list {
-  padding: 18rpx 18rpx;
+  padding: 18rpx 20rpx;
 }
 .skeleton-card {
   background: #fff;
-  display: flex;
-  gap: 18rpx;
-  padding: 16rpx;
   border-radius: 12rpx;
-  margin-bottom: 16rpx;
+  margin-bottom: 18rpx;
+  overflow: hidden;
+  box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
+}
+.skeleton-title {
+  width: 60%;
+  height: 34rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  margin: 20rpx;
+  border-radius: 6rpx;
+}
+.skeleton-content {
+  display: flex;
+  padding: 20rpx;
+  gap: 20rpx;
   align-items: center;
 }
 .skeleton-cover {
-  width: 160rpx;
-  height: 96rpx;
-  border-radius: 8rpx;
-  background: linear-gradient(90deg, #eee, #f6f6f6);
+  width: 220rpx;
+  height: 150rpx;
+  border-radius: 10rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
 }
 .skeleton-meta {
   flex: 1;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
 }
-.skeleton-title {
-  width: 60%;
-  height: 22rpx;
-  background: linear-gradient(90deg, #eee, #f6f6f6);
-  margin-bottom: 8rpx;
+.skeleton-summary {
+  width: 100%;
+  height: 28rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
+  margin-bottom: 12rpx;
   border-radius: 6rpx;
 }
 .skeleton-author {
-  width: 40%;
-  height: 16rpx;
-  background: linear-gradient(90deg, #eee, #f6f6f6);
-  margin-bottom: 8rpx;
-  border-radius: 6rpx;
-}
-.skeleton-summary {
-  width: 80%;
-  height: 16rpx;
-  background: linear-gradient(90deg, #eee, #f6f6f6);
+  width: 50%;
+  height: 24rpx;
+  background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
+  background-size: 200% 100%;
+  animation: skeleton-loading 1.5s infinite;
   border-radius: 6rpx;
 }
 
@@ -540,4 +557,14 @@ onPullDownRefresh(() => {
   padding: 12rpx 30rpx;
   border-radius: 12rpx;
 }
+
+@keyframes skeleton-loading {
+  0% {
+    background-position: 200% 0;
+  }
+  100% {
+    background-position: -200% 0;
+  }
+}
+
 </style>