---
url: /docs/guide/uniapp/api/device.md
---
# Device 设备线（前端调用视角）

> 后端契约：[手册 · device](/api-docs/v1/frontend/device)（授权中心 2 + 绑定 action 2 —— 字段表 / curl / 错误码）+ [手册 · page-direct#mytodayhealth](/api-docs/v1/frontend/page-direct#mytodayhealth)（健康卡）。
> 封装：`src/api/device.ts` —— **已收敛**（4 个函数封装完整）。消费页：[pages/mine-data-device-list](../pages/mine-data-device-list.md)（授权中心）+ [pages/index](../pages/index.md)（健康卡 —— 仍是页面直调）。
> 深入说明：[Device 设备线 API Docs](/api-docs/v1/device/) · [授权中心四态](/api-docs/v1/device/vendors)
> 错误契约：device 段是 **HTTP 400 / 401 / 404 + `{code,msg,data}`**（不是 OAuth 的「HTTP 200 + 码」那一套）—— 前端靠 `request.ts` 按 `statusCode` 分档。

## 端点一览（8 条 = 4 封装 + 4 action）

| # | 端点 | 前端调用 | 消费页 / 状态 |
|---|---|---|---|
| 1 | GET `api/v1/device/vendors` | `listVendors()` | 授权中心（`device-list.vue:206-219`）|
| 2 | POST `api/v1/device/vendors/{id}/auth-url` | `getAuthUrl(vendorId)` | 授权中心「去连接」 |
| 3 | POST `api/v1/device` `myBindings` | `listMyBindings()` | **已封装、当前零调用**（见下） |
| 4 | POST `api/v1/device` `unbind` | `unbindVendor(vendor)` | 授权中心「解除连接」 |
| 5 | POST `api/v1/device` `myTodayHealth` | **页面直调**（无封装） | 首页健康卡 |
| 6-8 | `listDeviceDailyActivity` / `myMetrics` / `mySleep` | **无封装、零调用** | ⚠️ 不在契约矩阵内 —— 见文末 |

***

## 一、授权中心

### listVendors（`listVendors()` · `src/api/device.ts:48-49`）

**一句话**：拉厂商清单 + 每家的四态绑定状态 —— 授权中心页 `device-list.vue` 每次 `onShow` 重拉（含 webview 授权返回后的自动刷新）。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/device.ts:48-49` → `listVendors()`（**无入参**，GET） |
| 触发时机 | `device-list.vue:205-219` `load()`；由 `onShow` 驱动（`:222-224`）—— 每次回到本页都刷新一次 |
| 鉴权 | `Bearer access`（`request.ts` 默认档） |

**必需 payload 键**：无（GET，无 body）。⚠️ 但 `request.ts` 对 GET 会**自动注入 `?cacheTime=<ts>`** 防缓存 —— 后端忽略，看到 URL 上多一个参数是正常的。

**响应读法**

```ts
const res = await listVendors();
const vendors = res?.data?.vendors ?? [];        // 中文注解：外层还有 vendors 包裹键，不是直接数组
devices.value = vendors.map((entry: VendorEntry) => ({
  id: entry.meta.id,                             // 中文注解：机器键（honor_oauth/zepp/…）—— 后续 auth-url 与 unbind 都传它，别传中文名
  name: entry.meta.displayName?.zh || entry.meta.id,  // 中文注解：zh 缺失时退回机器键，保证不显示空白
  logoUrl: entry.meta.logoUrl || '',             // 中文注解：空串走品牌色块兜底（图标加载失败也走兜底）
  status: entry.status,                          // 中文注解：四态由后端算好，前端不自己拼
}));
```

**四态 → UI（前端已实现的映射，照用别重写）**

| `status` | 文案（`STATUS_LABEL`） | UI 行为 |
|---|---|---|
| `bound` | 已连接 | 渲染「解除连接」按钮；点按钮或点行体 → 二次确认弹窗 |
| `available` | 未连接 | 渲染「去连接」按钮 + 右侧箭头；点行体 → `getAuthUrl` |
| `reviewing` | 正在审核中 | 无按钮；点行体 → toast「该设备厂家正在审核中，敬请期待」 |
| `error` | 暂时不可用 | 无按钮；点行体 → toast「该平台暂时不可用，请稍后再试」 |

> 优先级由后端定序（`bound > available > error > reviewing`）—— **前端不要自行推导状态**（例如"没绑定就是 available"是错的：未配凭据的厂家是 `reviewing`）。
> 四态含义与错误码见 [手册 · device#listvendors](/api-docs/v1/frontend/device#listvendors)。

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 任意业务失败 | `load()` 只有 `try/finally`（无 `catch`）→ **列表保持上一次的值**（首次进入则保持空）；`request.ts` 已统一 toast 文案 → 页面**不额外提示**，`loading` 会复位 |
| 401 | 拦截器 rotate 单飞续期后重放（[手册 G5](/api-docs/v1/frontend/#g5-·-rotate-由拦截器自动调用-——-不是业务直调)）；续不上则强登出回登录页 |

**⚠️ 坑位**

* **`health.detail` 恒 `undefined`**：前端 `VendorEntry` 声明了 `health.detail?`，但**后端没有这个键**（对应物是 `health.message`）。
  因 optional 且当前 UI 只消费 `meta` / `status`，运行时不炸；**要展示健康文案请读 `health.message`**（或让后端补别名）。
* **`health.configured` 是排障钥匙**（后端返回、前端未声明，但有用）：非 `bound` 时 ——
  `available` / `error` ⇒ `configured = true`；`reviewing` ⇒ `configured = false`（占位厂家或已实现未配凭据）。
  即「`reviewing` 到底是"厂家还没接"还是"凭据没配"」可由这个键区分。
* ⚠️ **产品口径变更（前端为准）**：`reviewing` 厂家 **2026-09-19 决策回滚 —— 12 平台全部展示**（`device-list.vue` 注释 + 提交 `cda7226`），
  点击给预期引导 toast。**手册 device.md 写的「当前产品决策：已隐藏」是更早（09-18 前）口径**，两处不一致时以本节为准（前端实际行为）。
* `meta.authType`（`oauth` / `ble` / `import` / `sync`）当前 UI **未消费** —— 若按形态隐藏入口，用它，别用硬编码厂商名判断。

### getAuthUrl（`getAuthUrl(vendorId)` · `src/api/device.ts:52-59`）

**一句话**：发起厂商授权 —— 拿到 `authUrl` 丢给 webview 加载；授权完成由厂商回调后端落绑定，前端**不轮询**。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/device.ts:52-59` → `getAuthUrl(vendorId)`（POST，body 传 `{}`） |
| 触发时机 | `device-list.vue:248-270` `doConnect()` —— **仅 `status === 'available'` 的行**会走到（其余态在点击分支就被 toast 拦下） |
| 鉴权 | `Bearer access` |
| 并发保护 | `connectingId` 挡重复点击（同页 `unbindingId` 同款） |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `vendorId`（路径段） | 行数据 `device.id` ← `entry.meta.id` | ✔ |
| `platform` | **当前封装不传**（签名只有 vendorId）—— 后端仅在 zepp 三端才需要，缺省合法 | ✖ |

**响应读法**

```ts
const res = await getAuthUrl(device.id);
const authUrl = res?.data?.authUrl;              // 中文注解：业务字段在 data.authUrl
if (!authUrl) { uni.showToast({ title: '未获取到授权链接', icon: 'none' }); return; }  // 中文注解：拿到空值就别跳，否则 webview 开个空白页
uni.navigateTo({ url: `/pages/webview/index?url=${encodeURIComponent(authUrl)}&title=...` });  // 中文注解：URL 必须 encodeURIComponent
```

`sessionKey` 后端在部分厂商会返回，**当前前端未消费**（若将来要做服务端校验授权会话再用）。

**授权闭环（关键：前端什么都不用做）**

```
点「去连接」→ getAuthUrl → authUrl → webview 打开厂商授权页
   → 厂商回调后端落绑定（honor/zepp 回调适配已上线）
   → 用户从 webview 返回本页 → onShow → load() → status 自动变 bound
```

> 即**不轮询、不手动置状态、不本地乐观更新** —— 状态只有一个来源（后端四态）。详见 [授权中心四态](/api-docs/v1/device/vendors)。

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 未知 vendor id | 拦截器 toast 后端 msg → `doConnect` 的 `catch` **有意留空**（注释：拦截器已提示）→ 用户只看到一条 toast，不跳转、`connectingId` 复位可重试 |
| 占位厂家 / 形态不支持 | 同上（不会天然发生：`reviewing` 行根本进不到这里；若通过其他入口调，按统一 toast 兜底）|
| 401 | 拦截器 rotate 重放；续不上强登出 |

> 码值与触发条件见 [手册 · device#getauthurl](/api-docs/v1/frontend/device#getauthurl)。

***

## 二、设备绑定 action（`POST api/v1/device`）

### myBindings（`listMyBindings()` · `src/api/device.ts:62-67`）

**一句话**：我的绑定列表（安全视图，不含 token 载体）—— **已封装但当前零调用**。

> ⚠️ 状态诚实登记：全仓 grep 只有定义处（`device.ts:62`）**没有任何页面调用**。
> 授权中心用的是 `listVendors` 的 `status`（不额外拉绑定表），所以这条目前是「备而未接」。
> 将来接线时记住读法：`res.data?.list ?? []` —— **把 `res.data` 直接当数组用会报 `map is not a function`**（本页首发版本正是这么写错的，勘误 D2）。

**响应读法**：`res?.data?.list` 是数组；元素键 `id` / `vendor` / `status` / `vendorUserId` / `lastSyncAt` / `createdAt`。
其中 `vendor` 与 `vendors[].meta.id` **同值域** —— 解绑直接传它即可精确匹配（无模糊）。字段可空性见 [手册 · device#mybindings](/api-docs/v1/frontend/device#mybindings)。

### unbind（`unbindVendor(vendor)` · `src/api/device.ts:70-75`）

**一句话**：解绑设备 —— 授权中心已连接行点「解除连接」（或点行体）→ 二次确认 → 调用。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | `src/api/device.ts:70-75` → `unbindVendor(vendor)`（`{action:'unbind', payload:{vendor}}`） |
| 触发时机 | `device-list.vue:273-300` `confirmUnbind()` → `uni.showModal` 确认 → `doUnbind()` |
| 鉴权 | `Bearer access` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `vendor` | 行数据 `device.id` ← `entry.meta.id`（如 `honor_oauth`） | ✔ |

**响应读法**

```ts
const res = await unbindVendor(device.id);
// 成功恒 res.data.ok === true —— 前端当前不读这个值，直接 toast + 重拉列表
uni.showToast({ title: '已解除连接', icon: 'success' });
load();
```

**失败时的前端动作**

| 情形 | 前端动作 |
|---|---|
| 未绑定该厂商（业务拒绝，**不是错误**） | 后端 `404` + `40403005`；拦截器 toast 后端 msg → ❌ **`doUnbind` 没有 `catch`，`load()` 不会执行** → 列表**仍是旧态**（那行还显示「已连接」），用户会以为"解绑没生效"。👉 现状兜底：**退出重进本页**（`onShow` 会重拉）即可恢复真实态。建议前端补：失败分支也调一次 `load()` |
| 参数问题（vendor 缺失等） | 拦截器 toast → 同上（列表不刷新） |
| 401 | 拦截器 rotate 重放；续不上强登出 |

> 码值 / 触发条件见 [手册 · device#unbind](/api-docs/v1/frontend/device#unbind)。

***

## 三、健康数据 action

### myTodayHealth（页面直调 · 首页健康卡）

**一句话**：首页 `loadToday()` 在 `onShow` 时拉一次 —— 一屏给齐步数 / 活动 / 心率 / 血氧 / 睡眠（**块状结构**）；「目标值」由 [stats · getTodayOverview](./stats.md) 提供。

**前端函数签名**

| 项 | 值 |
|---|---|
| 封装 | **页面直调**（未收敛）—— `src/pages/index/index.vue:105-146` `loadToday()`；消费段 `:124-141` |
| 触发时机 | 首页 `onShow`（`index.vue:147`）；与 `getTodayOverview` 并行（同一 `Promise.all`） |
| 鉴权 | `Bearer access` |

**必需 payload 键**

| 键 | 从哪来 | 必填 |
|---|---|---|
| `action` | 字面量 `'myTodayHealth'` | ✔ |
| `payload` | 该 action 无入参（前端连 `payload` 键都没传） | ✖ |

**响应读法**（⚠️ **块状结构** —— 读顶层会恒 `undefined`）

```ts
const h = res?.data;                                     // 中文注解：request 不解包，业务字段全在 res.data 之下
summary.steps     = h?.steps?.value ?? 0;                // 中文注解：steps 是块（{value,date}）| null —— 不是数字
summary.calories  = h?.todayActivity?.totalCalories ?? 0; // 中文注解：消耗在 todayActivity 里，顶层没有
summary.distance  = h?.todayActivity?.totalDistanceKm ?? 0;// 中文注解：⚠️ 距离也在 todayActivity 里；顶层【没有 distance】
heartRate.value   = h?.hr?.value ?? undefined;           // 中文注解：hr 也是块（{value,date}）| null
oxygen.value      = h?.spo2?.value ?? undefined;         // 中文注解：spo2 是块（{value,date}）| null —— v2.0.64 起有源（当日有 SPO2 读数则返块）
const sleep = h?.sleepXiaomi ?? h?.sleep;                // 中文注解：sleepXiaomi 是死分支（后端无此键），实际取到的是 sleep
```

> ✅ **正确读法**：**先取块、再取键** —— `h.todayActivity.totalDistanceKm`（距离）、`h.steps.value`（步数）、`h.hr.value`（心率），其中 `h` 就是上面那行取出的 `res.data`。
> ❌ **错误读法**：`res.data.distance` / `res.data.steps` 当数字用 —— 恒 `undefined` → `?? 0` 兜底 → **距离/步数静默恒为 0**。
> 这与 stats 的「热量目标恒 400」是**同一类事故的第 2 次**（文档写扁平、后端是块状）——**别照平铺猜，先 `console.log` 看层级**。
> 字段可空性 / 完整块内键见 [手册 · page-direct#mytodayhealth](/api-docs/v1/frontend/page-direct#mytodayhealth)。

**消费清单（页面实际读了什么）**

| 读法 | 用途 |
|---|---|
| `h.steps?.value` | 步数环（`?? 0`） |
| `h.sources?.steps` | 「步数数据来自：XX」小字 —— 经前端 `VENDOR_NAMES` 映射成中文显示名 |
| `h.todayActivity?.totalCalories` / `.totalDistanceKm` | 消耗环 / 距离 |
| `h.hr?.value` | 心率卡 |
| `h.spo2?.value` | 血氧卡（**v2.0.64 起有源** —— 当日有读数则返值，无则 `null`） |
| `h.sleep?.durationHours` | 睡眠卡「X小时Y分钟」（`!= null` 判空后才换算） |
| `h.sleep?.score` | 睡眠质量文案（`>=80 优 / >=60 良 / else 有待提高`） |

**⚠️ 坑位**

* **`sleepXiaomi` 是死分支**：`index.vue:132` 写 `h.sleepXiaomi ?? h.sleep`，但后端**没有 `sleepXiaomi` 这个键**（全仓无此字段）
  —— 实际永远回落到 `h.sleep`。留着无害，**别当成契约去依赖**。
* **`sleep.score` 已解禁**（v2.0.64）：值取 `sleep_summary` 当日 `score`，`sleepQuality` 判定逻辑（`>=80 优 / >=60 良 / else 有待提高`）可正常生效；
  当日无汇总行时仍为 `null` → `sleepQuality` 留空（诚实空态）。
* **`sleepDurationText` 的初值是 `'5小时1分钟'`**（`index.vue:94`）—— 与 `calorieGoal: 400` 同类的**渲染初值**：
  睡眠块为 `null`（或 `durationHours` 为 `null`）时卡片就显示这个假值。排查"为什么显示 5小时1分钟"先看这里，别去查后端。
* **「数据来自」小字会静默消失**：`VENDOR_NAMES` 是**前端硬编码 4 家**的映射表；后端 `sources.steps` 回的是机器键。
  新厂商上线而映射表没同步 ⇒ `?? ''` ⇒ `v-if="stepSourceText"` 判空 ⇒ **小字整条不渲染**（不报错、不提示）。
* **`spo2` 已解禁**（v2.0.64）：当日有 SPO2 读数（honor/小米采样或模板行）则返块 `{value,date}`；无读数 → `null` → 血氧卡显"无数据"（诚实空态，前端无需改动）。
* **失败是"全有全无"**：本端点与 `getTodayOverview` 共用一个 `Promise.all` + `catch` —— `Promise.all` **一损俱损**，
  展开见 [stats · 失败时的前端动作](./stats.md)。

### 已实现、前端零调用的 3 个 action（`listDeviceDailyActivity` / `myMetrics` / `mySleep`）

> ⚠️ **两条诚实声明**（V8）：
> ① 这 3 条**在 30 端点契约矩阵之外**（矩阵只覆盖 `src/api/*.ts` 封装 + 3 条页面直调）⇒ **字段未经审计**，下表仅为"将来接线时要知道的形状"，
> 真值以后端源码 `src/app/device/application/usecase/device_queries.py` 为准 —— **接线前请补矩阵**。
> ② 它们**当前前端零调用**：全仓 grep 无调用点，心率/睡眠详情页（`page-heart-rate-detail.vue`、`page-sleep-detail.vue`）**仍是本地 mock 常量**。
> 旧版本文档写的「详情页图表」「图表兜底数据源」是**未落地的计划**，不是现状。

| action | 入参（前端将来要传的键） | 响应形状（`res.data`） |
|---|---|---|
| `listDeviceDailyActivity` | `vendor?` / `days?`（缺省按 30 天） | `{list: [...], days}` —— 元素：`id` `vendor` `date` `step` `distanceM` `caloriesKcal` `sleepMin` `activeMin` `source` |
| `myMetrics` | `metricType`（**必填**，待确认取值域：源码注释列 `HR` / `STRESS` / `VO2MAX`（honor）、`HRV` / `REST_HR`（zepp）等）/ `start?` / `end?` | `{list: [...], total}` —— 元素：`id` `userId` `metricType` `calendarDate` `value` `sport` `level` `ingestedAt` |
| `mySleep` | `start?` / `end?` | `{list: [...], total}` —— 元素：`id` `userId` `calendarDate` `sleepStartGMT` `sleepEndGMT` `deepSleepSeconds` `lightSleepSeconds` `remSleepSeconds` `awakeSleepSeconds` `ingestedAt` |

> 三者都是 `POST api/v1/device` 的 action 分发（与 `myBindings` 同端点），响应都带 `list` 包裹键 —— **不要按直接数组消费**。

***

## 关键前端语义（跨端点）

* **`request` 不解包**：`res.data` 才是业务数据；`res.data.list` / `res.data.vendors` 这类**包裹键**一次都不能省。
* **四态只有一个来源**：`status` 由后端算（`bound > available > error > reviewing`）；前端只做渲染映射，别自行推导。
* **`meta.id` 是唯一标识**：`auth-url` 路径段、`unbind.payload.vendor` 都用它；中文名/展示名只用于显示。
* **X-Env 全程一致**：授权清单与健康数据都按库算；App 产线包 `X_ENV = "Prd"`（`src/config/index.ts` + `build-prod.sh` 构建期硬断言）——
  与 [手册 G2](/api-docs/v1/frontend/#g2-请求头) 同一原则（token 绑 X-Env，跨库即失效）。
* **失败先看 HTTP 状态**：device 段是 `HTTP 400/401/404 + code`（不是 OAuth 的 HTTP 200 那一套）—— 拦截器按 `statusCode` 分档，
  业务码是**字符串**，比较前 `String(body?.code)`。
* **数据缺失是常态，不是错误**：未绑定 / 未同步 ⇒ 各卡块为 `null`、`sources` 为 `{}` —— 前端一律 `?? 默认值` 容忍，**不要弹错误提示**。

## 待确认（未从矩阵确证，勿当契约用）

| # | 条目 | 卡在哪 |
|---|---|---|
| 1 | `listDeviceDailyActivity` / `myMetrics` / `mySleep` 的字段与取值域 | 不在 30 端点矩阵内（无前端封装 ⇒ 提取器盲区未补录）；上表键名取自后端源码，**未经矩阵审计** |
| 2 | `myMetrics.metricType` 的完整合法取值 | 仅确认源码注释所列（`HR`/`STRESS`/`VO2MAX`/`HRV`/`REST_HR`…），是否有白名单校验未核 |
| 3 | 「`myTodayHealth` 上线前曾 404」的运维事实 | 静态源码无法证实/证伪（该 action 分支现存在）—— 保留但**不可静态核** |
| 4 | `health.configured` 与 `status` 的**恒等**关系 | 由 `vendor_center.py` 映射顺序推出（非 bound 时 `reviewing ⇔ configured=false`）—— 未在矩阵登记，属源码推论 |

***

## 健康详情 10 action（2026-09-21 切片 1 · contract-matrix n=31..40）

> 与 `myBindings / unbind / myTodayHealth` 同为 `POST api/v1/device` action 协议 —— **同一路由前缀**，
> 仅 `body.action` 字段不同。封装层：`src/api/health.ts`（10 个 export）。
> 字段真值以 9-20 audit 报告 `tests/reports/contract-audit-device.md` 为准；本节仅为**矩阵覆盖留痕**，
> 让 `check_uniapp_api_docs` C1 守卫（合同矩阵 ↔ 前端文档）通过；详细字段含义后续阶段补。

10 个 action：`sleepDetail` / `heartRateDetail` / `stressDetail` / `oxygenDetail` /
`stepsDetail` / `caloriesDetail` / `strengthDetail` / `weightDetail` /
`activitySummary` / `recordDates`。

| action | 入参（`payload`） | 响应关键字段（`res.data`） |
|---|---|---|
| `sleepDetail` | `date?`（YYYY-MM-DD） | `summaries{day,week,month}` + `dayMetrics[]` + `stages[]` + `bedWake` + `weekRanges/monthRanges` + `periodMetrics` |
| `heartRateDetail` | `date?` | `summaryMap{4 档}` + `metricsMap{4 档}` + `rangeSets{4 档}` + `restingSource` |
| `stressDetail` | `date?` | `summaryMap/distributionMap/pointSets` 四档 + `card` 可空 |
| `oxygenDetail` | `date?` | `summaryMap/pointSets` 四档 + `overview/card` 可空 —— `card.series24` **v2.0.65 起已解禁**（raw.samples 逐时读路径：模板样本 + 小米逐样本） |
| `heartRateDetail`（补） | `date?` | `todaySeries` 逐时序列（honor `dynamicHeartRate` / 小米 `bpm` 双候选键） |
| `stepsDetail` | `date?` | `day/week/month` 三档 ActivityBlock（无 hour/year）|
| `caloriesDetail` | `date?` | 同 `stepsDetail` 形状 |
| `strengthDetail` | `date?` | 同 `stepsDetail` 形状 |
| `weightDetail` | `date?` | `current` 块 + `dataMap/fatMap/muscleMap` 四档 |
| `activitySummary` | `date?` + `month?`（'YYYY-MM' 才返回 monthDays） | `goals/day/week/[monthDays?]` |
| `recordDates` | `days?`（1-366） | `days + dates[]` 倒序（四表并集）|

10 个均带 `vendor?: string`（用户无设备 → 键不存在 — 判空用 `'vendor' in data`）+
`dataSource?: 'default_template'`（仅降级时出现）。
