# 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` — 同样应用了骨架与加载/错误处理(此处也已做类似改动)。
关键代码片段
- 错误优先渲染分支(示例):
```
...
...
暂无药品
列表
```
- 错误格式化函数(示例):
```
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) 模板片段(错误区 + 骨架 + 空态顺序)
```
加载失败:{{ loadError || '网络或服务器异常' }}
暂无药品
...
```
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 并在更多列表页统一使用。