---
url: /docs/api-docs/v1/frontend/wiring-guide.md
---
# 前端接线必读 —— 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:138`
* `src/pages/index/components/stress-record-view.vue:47`
* `src/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:56` `props.zones.length`
* `src/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` 也基本不会带** —— 向导提交本身会写一条真实体重行，
按「有真数据就不标记」的保守语义，这两块整块不标记。**细节与影响见**
[取值速查 · 数据来源标记](./values#数据来源标记datasource--降级数据的识别与处理)。

📖 取值语义详见[取值速查 · 数据来源标记](./values#数据来源标记datasource--降级数据的识别与处理)。

***

## 二、逐接口要点（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 轨迹静默重报** 是唯二「不做就一定会出问题且不易发现」的，
建议优先处理。
