# 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 并在更多列表页统一使用。