uni-app 列表页面加载失败显示与骨架重试处理总结.md 9.4 KB

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. 状态管理

    • 新增三个响应式状态:isLoadingisErrorloadError
    • 请求开始时:isLoading=true; isError=false; loadError=''
    • 请求成功时:填充 medicinesisError=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.messagee.data.messagee.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 — 主实现文件:状态、模板分支、formatErrorretryLoad、样式、骨架样式。
  • 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 并在更多列表页统一使用。