Appearance
前端接线必读 —— 19 个新 API 的对接陷阱与兜底
生成:2026-09-20 · 依据:
tests/reports/contract-audit-device.md(606 行)+tests/reports/contract-audit-sport-healtharchive.md(430 行)—— 逐字段穷举核对, 覆盖 19 个 action 的每一个响应字段(304 条一致 / 59 条类型不符 / 34 条后端缺失 / 83 条多余)。这份文档解决什么问题:后端这 19 个接口已实测可用,但前端的 10 个页面/组件 目前零接线(全是硬编码假数据)。直接照现有模板接线会崩 —— 本文列出必做的兜底。
📌 先读「一、必做兜底」7 条,再看「二、逐接口要点」。
一、必做兜底(8 条 —— 不做会崩、静默出错或误导用户)
⚠️ 根因:withDefaults 只补 undefined,不补 null
这是绝大多数崩溃的共同根因。后端按「无数据源就显式返 null,不伪造」的设计 (见 device/CLAUDE.md),而前端 withDefaults(defineProps<{data?: T[]}>(), {data: () => []})只在传 undefined 时用默认值 —— 传 null 会绕过默认值,于是 props.data.map 直接抛 TypeError。
统一兜底写法:所有可能为 null 的数组/对象 prop,在父组件传参时就地兜底:
vue
<!-- ✅ 正确 -->
<QmSportChartCard :data="detail.heartSeries ?? []" :zones="detail.heartZones?.zones ?? []" />
<!-- ❌ 会崩(data 为 null 时 props.data.map 抛 TypeError) -->
<QmSportChartCard :data="detail.heartSeries" :zones="detail.heartZones.zones" />1. 🔴 心率详情页首屏白屏 —— 最高优先级
位置:src/pages/detail/page-heart-rate-detail.vue:89
ts
const period = ref<Period>('hour'); // ← 默认档位是 hour
const summary = computed(() => summaryMap[period.value]); // :101问题:后端 heartRateDetail.summaryMap.hour 恒为 null(逐小时序列无数据源), 而模板 :14 直接读 summary.label(即对 null 取属性)→ TypeError → 整页白屏。
兜底(二者选一,建议都做):
diff
- const summary = computed(() => summaryMap[period.value]);
+ const summary = computed(() => summaryMap[period.value] ?? summaryMap.day ?? { label: '', value: '', date: '' });或把默认档位改成 'day'(后端 day 档有值):
diff
- const period = ref<Period>('hour');
+ const period = ref<Period>('day');⚠️ 同时检查 :105 的 summary.value.value —— 同一处 null 也会崩。
2. 🔴 三个首页卡对 null 取 .length → 崩
位置(三处同一写法):
src/pages/index/components/heart-rate-view.vue:138src/pages/index/components/stress-record-view.vue:47src/pages/index/components/oxygen-record-view.vue:48
ts
const source = props.series.length ? props.series : defaultSeries; // ← series 为 null 即崩问题:后端这三个 action 的 todaySeries / 首页卡的 series 恒为 null。
兜底:
diff
- const source = computed(() => props.series.length ? props.series : defaultSeries);
+ const source = computed(() => props.series?.length ? props.series : defaultSeries);或在父页面传参时::series="data.todaySeries ?? undefined"(传 undefined 才会命中默认值)。
3. 🔴 运动详情 5 张图 + 分区卡 —— null 直绑会崩
位置:src/components/qm-sport-chart-card.vue:16-17 (withDefaults(..., { data: () => [], zones: () => [] }))+ :26 props.data.map / :6 v-if="zones.length"
问题:runSessionDetail 的 heartSeries / cadenceSeries / strideSeries / elevationSeries / heartZones 恒为 null(上报体只有经纬度/精度/速度/距离/时长, 后端明确不编造)。
兜底:父页面传参时 ?? [](见上文统一写法)。这是本次审计唯一的 TypeError 级风险。
4. 🔴 心率区间 / 速配区间 —— zones 可 null
位置:
src/pages/mine/info/components/heart-rate-zones.vue:56props.zones.lengthsrc/pages/mine/info/components/compatibility-range-view.vue:51
兜底::zones="data.heartRateZones?.zones ?? []" / ?? []。
5. 🔴 轨迹上报静默失败 —— 每 10s 重报同一坐标
位置:src/utils/run-coordinate-reporter.ts:98
ts
const result = await reportApi(reportData);
if (result.ok) { ... } else { console.warn('[run] 运动经纬度上报失败', error); }问题:src/utils/request.ts:160 的 resolve(body) 返回的是完整信封{code, msg, data}(不剥壳 —— 项目既有约定)。而 reporter 直接取 result.ok → 恒为 undefined → 每次都走失败分支。
后端响应本身是对的(data 恰为 {ok: true, receivedAt: <ms>},与 TS 类型逐字节吻合) —— 差的只是取 data 这一层。
兜底(接 reportApi 时):
diff
- .then(r => ({ ok: r.ok, receivedAt: r.receivedAt }))
+ .then(r => r.data) // 或 .then(r => ({ ok: r.data?.ok ?? false, receivedAt: r.data?.receivedAt ?? 0 }))⚠️ 不做这层会静默失败 —— HTTP 200、后端已落库,但前端认为失败并在下一个 10s 周期重报。
6. 🔴 startedAt 时区陷阱 —— 显示时间差 8 小时
位置:finishRun / runSessionDetail 的 startedAt/endedAt
后端返回的是 naive UTC ISO 串(如 "2026-09-20T07:56:53.535739",无时区后缀)—— JS new Date("2026-09-20T07:56:53") 会按本地时区解析 → 比真实东八区时刻早/晚 8 小时。
⚠️ 同批 action 格式还不一致:startRun 的 startedAt 是 epoch ms(无此问题), 而 finishRun/runSessionDetail 是 ISO 串。
兜底:
ts
// naive UTC ISO 串 → 正确时刻
const toLocalDate = (s: string) => new Date(s.endsWith('Z') ? s : `${s}Z`);7. ⚠️ addWeightRecord 的 extra="forbid" —— 原样提交会被 400 拒
位置:src/pages/form/add_weight-page.vue(提交本地 WeightRecord)
本地记录结构含 id / createdAt,而后端 payload 用了 extra="forbid" → 带多余键会 400 id: Extra inputs are not permitted(会阻断提交)。
兜底:提交时只挑后端声明过的字段:
ts
request({ url: 'api/v1/sport', method: 'post', data: {
action: 'addWeightRecord',
payload: { date, time, weight, bodyFat: bodyFat ?? null }, // ← 不要带 id/createdAt
}})8. ⚠️ 默认数据模板 —— 用户没绑设备时后端会返回示例数据,前端必须标注
这不是崩溃问题,是诚信问题:用户没绑任何设备,健康页却有 7 天的步数/心率/睡眠曲线。 如果前端不说明,用户会以为那是自己测出来的。
后端行为:用户四维齐全但没有任何设备数据时,后端按四维画像算一套 7 天示例数据 (来源键 default_template)在响应里返回;这些响应多带一个 dataSource 键。
兜底:拿到响应后按顶层 dataSource 判断,挂一条提示。
⭐ 前端已有现成组件,一行即可(qm-frontend-api,easycom 免 import)—— 它自己判、不是示例数据就整块不渲染:
vue
<qm-sample-data-notice :data="detail" />自己判文案时用 @/utils/sample-data(isSampleData / sampleNoticeText):
ts
const isSample = isSampleData(data) // 等价于 data?.dataSource === 'default_template'
// myTodayHealth / archive 还会带 data.defaultDims,例如 ['sleep','steps'] —— 只有这些维度是示例⚠️ 要覆盖全部会降级的端点,不只是首页:
| 域 | 端点 |
|---|---|
| device | myTodayHealth / myMetrics / mySleep / listDeviceDailyActivity / 健康详情 10(heartRateDetail 等)—— 注入在响应出口统一做 |
| health_archive | archive(健康档案页)—— 单独在用例层算(cz__inject_data_source 只存在于 device 域)。🔴 必须加:该页的生理/心肺/心率区间全取自 device 五表 |
✅ 不该加的(加了是永不渲染的死标记):
myBindings—— 绑定列表永不降级(模板不产生绑定)- sport 域整域(
runSessionDetail/myRunRecords/ …)——sport_checkin是用户自己跑出来的,模板不写
⚠️ 反向也要判对:有真实数据的响应不带 dataSource 键 —— 别写成 data.dataSource !== 'real' 之类,那会在正常用户身上挂提示。
✅ myBindings 例外:绑定列表永不降级(模板不产生绑定),不带该键,无需处理。
⚠️ weightDetail / recordDates 也基本不会带 —— 向导提交本身会写一条真实体重行, 按「有真数据就不标记」的保守语义,这两块整块不标记。细节与影响见取值速查 · 数据来源标记。
📖 取值语义详见取值速查 · 数据来源标记。
二、逐接口要点(19 个)
device 健康详情(10)
| action | 接线要点 |
|---|---|
sleepDetail | summaries / score / bedWake 可为 null(无睡眠数据的用户)→ 需 null 防护 |
heartRateDetail | 🔴 见兜底 #1;另 todaySeries 恒 null;缺「平均心率」「睡眠平均心率」「步行平均心率」(无源) |
stressDetail | summaryMap[period] / distributionMap[period] 无数据档为 null → 需防护 |
oxygenDetail | 同上 |
stepsDetail | ✅ 最干净(42 一致 / 0 缺失)。steps 是 number(前端声明 string)—— 纯插值无害 |
caloriesDetail | ✅ 干净。同上 number/string |
strengthDetail | ✅ 最干净(16 一致 / 0 缺失 / 0 多余) |
weightDetail | 缺热量管理四值、目标体重/进度(属其他域);fatMap/muscleMap 周月年档缺 stats 键 |
activitySummary | ⚠️ 日历页必须传 payload.month="YYYY-MM",否则 monthDays 不返回 |
recordDates | ✅ 干净 |
sport 运动域(6)
| action | 接线要点 |
|---|---|
startRun | activityType 只接受 running/walking/cycling;startedAt 是 epoch ms |
reportRunCoordinate | 🔴 见兜底 #5。请求 10 字段与 TS 类型全对齐,extra=forbid 不拒 |
finishRun | 🔴 startedAt 见兜底 #6;摘要缺总卡路里/累计上升/下降 |
myRunRecords | 缺 记录行的 symbol/value/unit(symbol 前端可本地映射);avgPace 是 "6:09" 格式,需前端格式化 |
runSessionDetail | 🔴 见兜底 #3 + #6;15 条缺失(5 个序列 + heartZones + 摘要三项) |
addWeightRecord | 🔴 见兜底 #7 |
health_archive 健康档案(3)
| action | 接线要点 |
|---|---|
archive | 7 组件;🔴 heartRateZones.zones / compatibilityRanges.zones 可 null(兜底 #4);method 是枚举键,展示要用 methodLabel;heartRateRecovery 恒 null(厂商云无此指标);🔴 会降级 —— 生理/心肺/心率区间全取自 device 五表,必须挂示例数据提示(兜底 #8)。⚠️ 但不是整页假数据(基础信息/病史/跑步能力是真的)→ 提示要精确到 defaultDims,用现成组件即可 |
saveHealthIncident | ✅ 干净 |
saveHeartRateZones | method 传枚举键(reserve/maximum/lactate),不是中文标签 |
三、后端明确「不伪造」而返 null 的字段(不要当 bug 报)
这些是有意的设计决策(device/CLAUDE.md 明示)—— 无数据源就返 null,绝不编造。 接线时按「无数据」渲染,不要报缺陷:
| 字段 | 原因 |
|---|---|
| 逐小时 24 点序列(日档 steps/calories/strength) | 设备只上行日聚合,无小时明细 |
睡眠逐段波形 daySegments、睡眠心率/呼吸率/血氧 | 厂商云不提供 |
| 步行平均心率 | 无步数-心率关联源 |
heartSeries/cadenceSeries/strideSeries/elevationSeries | 轨迹上报体不含这些 |
| 目标体重 / 进度百分比 / 热量管理四值 | 属目标域与 stats 域 |
heartRateRecovery / vo2max(部分厂商)/ hrv(仅 zepp 写) | 厂商映射无此指标 |
四、完整对照表(要核某个具体字段时查这里)
- device 10 action:
tests/reports/contract-audit-device.md(606 行,含每个 action 的后端 dump + 前端 TS 类型(标了文件:行号)+ 逐叶子对照表) - sport 6 + health_archive 3:
tests/reports/contract-audit-sport-healtharchive.md(430 行,同上;含reportRunCoordinate的字节级核对) - 响应原件:
/tmp/contract-audit/*.json
五、一句话总结
后端已就绪(19 接口实测可用,304 条字段一致)。 接线时必做 7 条兜底 —— 其中 #1 心率页白屏 与 #5 轨迹静默重报 是唯二「不做就一定会出问题且不易发现」的, 建议优先处理。