ソースを参照

feat(medicine.vue): 优化加载失败提示与重试功能,增加错误格式化与骨架加载效果

mcbaiyun 2 週間 前
コミット
a96f9a4737

+ 182 - 0
docs/uni-app 列表页面加载失败显示与骨架重试处理总结.md

@@ -0,0 +1,182 @@
+# uni-app 列表页面加载失败显示与骨架重试处理总结
+
+概述
+-	本文总结了在 `medicine.vue`(药品管理列表页)中遇到的“加载失败时页面显示为‘暂无数据’且错误信息显示为 `[object Object]`”的问题,记录了定位、根因、完整修复(含骨架加载、错误提示与重试交互、样式优化)以及相关经验教训。
+
+背景(问题现象)
+-	页面在请求后端数据失败时并未显示错误提示;相反显示“暂无药品”。
+-	错误弹窗中显示的错误文本是 `加载失败:[object Object]`,对用户无帮助。
+
+根因分析
+-	模板渲染顺序导致错误提示被空列表判断覆盖:原模板为 `v-if="isLoading"` -> `v-else-if="filteredMedicines.length === 0"` -> `v-else-if="isError"`。当请求失败且列表为空,空列表分支先命中,错误分支被隐藏。
+-	错误对象直接用 `String(err)` 或错误对象未格式化,导致显示 `[object Object]`。
+
+解决方案(概述)
+1. 状态管理
+  - 新增三个响应式状态:`isLoading`、`isError`、`loadError`。
+  - 请求开始时:`isLoading=true; isError=false; loadError=''`。
+  - 请求成功时:填充 `medicines`,`isError=false; loadError=''`。
+  - 请求失败时:`isError=true; loadError=格式化后的错误字符串`。
+
+2. 模板调整(错误优先)
+  - 调整渲染分支顺序,确保错误视图优先于空列表视图:
+    - `v-if="isLoading"`(骨架)
+    - `v-else-if="isError"`(错误提示 + 重试)
+    - `v-else-if="!isError && filteredMedicines.length === 0"`(空列表)
+    - `v-else`(正常列表)
+
+3. 错误格式化
+  - 新增 `formatError(e)` 辅助函数,优先提取常见字段:`e.message`、`e.data.message`、`e.response.data.message`,否则尝试 `JSON.stringify(e)` 或 `String(e)`,保证展示可读文本而非 `[object Object]`。
+
+4. 重试交互
+  - 增加 `retryLoad()`,内部直接调用 `loadMedicineList()`。
+  - 错误区域显示一个紧凑的图标 + 文本“重试”按钮,点击触发重试。
+
+5. 骨架与样式优化
+  - 增加骨架(skeleton)占位模块:`.skeleton-list`、`.skeleton-line`、`.skeleton-btn`,并添加 shimmer 动画(`@keyframes shimmer`)。
+  - 错误区视觉优化:用 `.load-error` 容器包裹图标、错误文本和操作,使用轻微红色背景/边框以提示严重性。
+  - 重试按钮 `.retry-btn` 采用扁平且紧凑的尺寸(示例:高度 48rpx,min-width 96rpx,icon size 20),保证行高不会被压缩。
+  - 在加载/出错期间禁用行内操作(编辑/删除)和新建浮动按钮(通过 `:disabled` 或 `.fab.disabled`)。
+
+代码位置(已修改文件)
+-	`src/pages/doctor/manage/medicine.vue` — 主实现文件:状态、模板分支、`formatError`、`retryLoad`、样式、骨架样式。
+-	`src/pages/doctor/manage/critical-values.vue` — 同样应用了骨架与加载/错误处理(此处也已做类似改动)。
+
+关键代码片段
+-	错误优先渲染分支(示例):
+
+```
+<view v-if="isLoading" class="skeleton-list">...</view>
+<view v-else-if="isError" class="load-error">...<button @click="retryLoad">重试</button></view>
+<view v-else-if="!isError && filteredMedicines.length === 0">暂无药品</view>
+<view v-else class="medicine-list">列表</view>
+```
+
+- 错误格式化函数(示例):
+
+```
+const formatError = (e) => {
+  if (!e) return ''
+  if (typeof e === 'string') return e
+  if (e.message) return e.message
+  if (e.data && e.data.message) return e.data.message
+  if (e.response && e.response.data && e.response.data.message) return e.response.data.message
+  try { const s = JSON.stringify(e); if (s && s !== '{}' && s !== 'null') return s } catch(_){}
+  return String(e)
+}
+```
+
+和 `loadMedicineList` 中的使用:
+
+```
+try {
+  const res = await getMedicineList(params)
+  if (res?.data?.code === 200) { ... }
+  else { loadError.value = formatError(res && res.data ? res.data : res) }
+} catch (err) { loadError.value = formatError(err) }
+```
+
+样式与完整代码清单(便于复制粘贴)
+
+1) 模板片段(错误区 + 骨架 + 空态顺序)
+
+```
+<!-- 骨架 -->
+<view v-if="isLoading" class="skeleton-list">
+  <!-- repeat skeleton-card -->
+</view>
+
+<!-- 错误优先 -->
+<view v-else-if="isError" class="load-error compact">
+  <view class="error-left">
+    <uni-icons type="warn" size="34" color="#d93025" />
+    <text class="error-text">加载失败:{{ loadError || '网络或服务器异常' }}</text>
+  </view>
+  <view class="error-actions">
+    <button class="retry-btn" @click="retryLoad">
+      <uni-icons type="refresh" size="20" color="#fff" />
+      <text class="retry-text">重试</text>
+    </button>
+  </view>
+</view>
+
+<!-- 空列表(只有在无错误时展示) -->
+<view v-else-if="!isError && filteredMedicines.length === 0" class="empty-state">
+  <text class="empty-text">暂无药品</text>
+</view>
+
+<!-- 正常列表 -->
+<view v-else class="medicine-list"> ... </view>
+```
+
+2) 关键样式(CSS)
+
+```
+/* 骨架 shimmer */
+.skeleton-list { padding: 18rpx 18rpx; }
+.skeleton-card { background: #fff; display: flex; gap: 18rpx; padding: 16rpx; border-radius: 12rpx; margin-bottom: 16rpx; align-items: center; justify-content: space-between; }
+.skeleton-meta { flex: 1; display: flex; flex-direction: column; gap: 10rpx; }
+.skeleton-line { background: linear-gradient(90deg, #eee 25%, #f6f6f6 50%, #eee 75%); border-radius: 6rpx; background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
+.skeleton-line.title { width: 40%; height: 28rpx; }
+.skeleton-line.summary { width: 60%; height: 18rpx; }
+.skeleton-actions { display:flex; gap: 12rpx; align-items: center; }
+.skeleton-btn { width: 100rpx; height: 40rpx; background: linear-gradient(90deg, #eee 25%, #f6f6f6 50%, #eee 75%); border-radius: 20rpx; background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
+
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+/* 错误区 */
+.load-error { display:flex; gap: 20rpx; align-items:center; justify-content: space-between; padding: 30rpx; margin: 18rpx 20rpx; background: #fff7f7; border: 1rpx solid #ffd2d2; border-radius: 12rpx; }
+.load-error.compact { padding: 20rpx; }
+.load-error .error-left { display:flex; gap:12rpx; align-items:center; flex:1; }
+.load-error .error-actions { display:flex; align-items:center; }
+
+/* 重试按钮 */
+.retry-btn { display:flex; align-items:center; justify-content:center; gap:8rpx; background: #d93025; color: #fff; height: 48rpx; min-width: 96rpx; padding: 0 12rpx; border-radius: 12rpx; font-size: 24rpx; border: none; }
+.retry-text { color: #fff; font-size: 24rpx; line-height: 48rpx; }
+
+/* disabled */
+.fab.disabled { opacity: 0.6; pointer-events: none; }
+button[disabled] { opacity: 0.6; pointer-events: none; }
+```
+
+3) 错误格式化函数(完整)
+
+```
+const formatError = (e: any): string => {
+  if (!e) return ''
+  if (typeof e === 'string') return e
+  if (typeof e === 'number' || typeof e === 'boolean') return String(e)
+  if (e.message && typeof e.message === 'string') return e.message
+  if (e.data && typeof e.data.message === 'string') return e.data.message
+  if (e.response && e.response.data && typeof e.response.data.message === 'string') return e.response.data.message
+  try {
+    const s = JSON.stringify(e)
+    if (s && s !== '{}' && s !== 'null') return s
+  } catch (_) {}
+  return String(e)
+}
+```
+
+说明:以上样式与函数可直接拷贝到页面中,或将 `formatError` 提取为共享 util 并在多个列表页使用。
+
+与已有文档的关联
+-	仓库中已有文档 `docs/uni-app 页面加载提示对比分析(showLoading vs 骨架动画).md`(见 docs),讨论了何时使用全局 `uni.showLoading()` 与何时使用骨架屏。本次修复在实践上采用了骨架屏(适用于列表类、加载时间较长或希望提升感知速度的场景),与该文档结论一致。
+-	结论:这类“加载/错误显示”的问题在项目中并非首次出现,因此请将本文件视为对已有经验的补充与具体案例实操:这是一次以前出现过的问题的再现与改进(参见上面提到的对比分析文档)。
+
+经验教训与建议
+-	错误视图要优先于空内容视图:任何以数据为空为判断的 UI 分支都应该在确认没有错误的前提下才展示。
+-	不要直接展示未经格式化的错误对象:对后端或 axios 返回的错误做统一格式化,保证用户看到可读文本。
+-	为关键交互(编辑/删除/新建)在加载或错误期间禁用操作,避免用户误操作或引发二次错误。
+-	保持骨架与真实内容在结构上的一致性,骨架不应显著改变页面布局高度(避免骨架高度过小或过大造成跳动感)。
+-	把通用逻辑(如 `formatError`)抽成工具函数复用,减少重复实现并统一错误文案策略。
+
+后续改进(可选)
+-	把 `formatError` 提取到 `src/utils/error.ts` 并在其他页面复用。
+-	为重试按钮添加 loading 状态(点击后禁用按钮并显示小图标转圈),提升交互反馈。
+-	增加埋点:统计加载失败率、重试次数,帮助定位后端或网络问题。
+
+结束语
+-	此次修复既是一次具体页面错误处理的修补,也把项目中关于加载/骨架/错误显示的经验落地。已将关键改动合并到 `medicine.vue`(和 `critical-values.vue` 的类似改动),建议将 `formatError` 提取为共享 util 并在更多列表页统一使用。

+ 105 - 14
src/pages/doctor/manage/medicine.vue

@@ -30,7 +30,21 @@
       </view>
     </view>
 
-    <view v-else-if="filteredMedicines.length === 0" class="empty-state">
+    <!-- 错误优先展示(避免空列表遮盖错误信息) -->
+    <view v-else-if="isError" class="load-error compact">
+      <view class="error-left">
+        <uni-icons type="warn" size="34" color="#d93025" />
+        <text class="error-text">加载失败:{{ loadError || '网络或服务器异常' }}</text>
+      </view>
+      <view class="error-actions">
+        <button class="retry-btn" @click="retryLoad">
+          <uni-icons type="refresh" size="20" color="#fff" />
+          <text class="retry-text">重试</text>
+        </button>
+      </view>
+    </view>
+
+    <view v-else-if="!isError && filteredMedicines.length === 0" class="empty-state">
       <text class="empty-text">暂无药品</text>
     </view>
 
@@ -45,8 +59,8 @@
           <text class="medicine-pinyin">{{ medicine.pinyinFirstLetters }}</text>
         </view>
         <view class="medicine-actions">
-          <button class="action-btn edit-btn" @click="editMedicine(medicine)">编辑</button>
-          <button class="action-btn delete-btn" @click="deleteMedicineHandler(medicine.id)">删除</button>
+          <button class="action-btn edit-btn" @click="editMedicine(medicine)" :disabled="isLoading || isError">编辑</button>
+          <button class="action-btn delete-btn" @click="deleteMedicineHandler(medicine.id)" :disabled="isLoading || isError">删除</button>
         </view>
       </view>
     </view>
@@ -106,7 +120,7 @@
       </view>
     </view>
   </view>
-  <view class="fab" @click="showAddModal" role="button" aria-label="新建药品">
+  <view class="fab" :class="{ disabled: isLoading || isError }" @click="showAddModal" role="button" aria-label="新建药品">
     <view class="fab-inner">+</view>
   </view>
 </template>
@@ -137,6 +151,9 @@ const medicines = ref<Medicine[]>([])
 const searchKeyword = ref('')
 // 加载态
 const isLoading = ref(false)
+// 错误与重试
+const isError = ref(false)
+const loadError = ref('')
 
 // 模态框控制
 const showModal = ref(false)
@@ -179,6 +196,25 @@ function debounce<T extends (...args: any[]) => void> (fn: T, wait = 400) {
   }
 }
 
+// 把各种可能的错误对象格式化为用户友好的字符串
+const formatError = (e: any): string => {
+  if (!e) return ''
+  if (typeof e === 'string') return e
+  if (typeof e === 'number' || typeof e === 'boolean') return String(e)
+  if (e.message && typeof e.message === 'string') return e.message
+  if (e.data && typeof e.data.message === 'string') return e.data.message
+  // 常见 axios response 可能在 e.response.data
+  if (e.response && e.response.data && typeof e.response.data.message === 'string') return e.response.data.message
+  try {
+    const s = JSON.stringify(e)
+    // 避免返回 "{}"
+    if (s && s !== '{}' && s !== 'null') return s
+  } catch (_) {
+    // ignore
+  }
+  return String(e)
+}
+
 // 当用户按下确认时,立即搜索
 const handleSearchImmediate = () => {
   pagination.value.pageNum = 1
@@ -330,6 +366,8 @@ const scanBarcode = () => {
 // 加载药品列表
 const loadMedicineList = async () => {
   isLoading.value = true
+  isError.value = false
+  loadError.value = ''
   try {
     const params = {
       pageNum: pagination.value.pageNum,
@@ -341,23 +379,30 @@ const loadMedicineList = async () => {
     if (result?.data?.code === 200) {
       medicines.value = result.data.data.records
       pagination.value.total = result.data.data.total
+      isError.value = false
+      loadError.value = ''
     } else {
-      uni.showToast({
-        title: '获取药品列表失败',
-        icon: 'none'
-      })
+      const msg = formatError(result && result.data ? result.data : result) || '获取药品列表失败'
+      isError.value = true
+      loadError.value = msg
+      uni.showToast({ title: msg, icon: 'none' })
     }
   } catch (error) {
-    uni.showToast({
-      title: '获取药品列表失败',
-      icon: 'none'
-    })
+    isError.value = true
+    const msg = formatError(error) || '获取药品列表失败'
+    loadError.value = msg
+    uni.showToast({ title: msg, icon: 'none' })
   }
   finally {
     isLoading.value = false
   }
 }
 
+const retryLoad = async () => {
+  // 直接调用加载函数以重试
+  await loadMedicineList()
+}
+
 // 页面加载时
 onMounted(() => {
   loadMedicineList()
@@ -448,13 +493,59 @@ onMounted(() => {
 .skeleton-list { padding: 18rpx 18rpx; }
 .skeleton-card { background: #fff; display: flex; gap: 18rpx; padding: 16rpx; border-radius: 12rpx; margin-bottom: 16rpx; align-items: center; justify-content: space-between; }
 .skeleton-meta { flex: 1; display: flex; flex-direction: column; gap: 10rpx; }
-.skeleton-line { background: linear-gradient(90deg, #eee, #f6f6f6); border-radius: 6rpx; }
+.skeleton-line { background: linear-gradient(90deg, #eee 25%, #f6f6f6 50%, #eee 75%); border-radius: 6rpx; background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
 .skeleton-line.title { width: 40%; height: 28rpx; }
 .skeleton-line.summary { width: 60%; height: 18rpx; }
 .skeleton-actions { display:flex; gap: 12rpx; align-items: center; }
-.skeleton-btn { width: 100rpx; height: 40rpx; background: linear-gradient(90deg, #eee, #f6f6f6); border-radius: 20rpx; }
+.skeleton-btn { width: 100rpx; height: 40rpx; background: linear-gradient(90deg, #eee 25%, #f6f6f6 50%, #eee 75%); border-radius: 20rpx; background-size: 200% 100%; animation: shimmer 1.2s linear infinite; }
 .skeleton-btn.small { width: 60rpx; }
 
+@keyframes shimmer {
+  0% { background-position: 200% 0; }
+  100% { background-position: -200% 0; }
+}
+
+/* Error view */
+.load-error {
+  display:flex;
+  gap: 20rpx;
+  align-items:center;
+  justify-content: space-between;
+  /* 恢复较大的内边距以匹配列表项行高 */
+  padding: 30rpx;
+  margin: 18rpx 20rpx;
+  background: #fff7f7;
+  border: 1rpx solid #ffd2d2;
+  border-radius: 12rpx;
+}
+error-text { color: #d93025; font-size: 28rpx; }
+
+/* compact layout */
+.load-error.compact { padding: 20rpx; }
+.load-error .error-left { display:flex; gap:12rpx; align-items:center; flex:1; }
+.load-error .error-actions { display:flex; align-items:center; }
+.retry-btn {
+  display:flex;
+  align-items:center;
+  justify-content:center;
+  gap:8rpx;
+  background: #d93025;
+  color: #fff;
+  height: 48rpx;
+  min-width: 96rpx;
+  padding: 0 12rpx;
+  border-radius: 12rpx;
+  font-size: 24rpx;
+  border: none;
+}
+.retry-text { color: #fff; font-size: 24rpx; line-height: 48rpx; }
+
+/* fab disabled */
+.fab.disabled { opacity: 0.6; pointer-events: none; }
+
+/* disabled buttons */
+button[disabled] { opacity: 0.6; pointer-events: none; }
+
 .medicine-item {
   display: flex;
   justify-content: space-between;