Skip to content

Device 设备线(前端调用视角) ​

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

端点一览(8 条 = 4 封装 + 4 action) ​

#端点前端调用消费页 / 状态
1GET api/v1/device/vendorslistVendors()授权中心(device-list.vue:206-219)
2POST api/v1/device/vendors/{id}/auth-urlgetAuthUrl(vendorId)授权中心「去连接」
3POST api/v1/device myBindingslistMyBindings()已封装、当前零调用(见下)
4POST api/v1/device unbindunbindVendor(vendor)授权中心「解除连接」
5POST api/v1/device myTodayHealth页面直调(无封装)首页健康卡
6-8listDeviceDailyActivity / 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。

失败时的前端动作

情形前端动作
任意业务失败load() 只有 try/finally(无 catch)→ 列表保持上一次的值(首次进入则保持空);request.ts 已统一 toast 文案 → 页面不额外提示,loading 会复位
401拦截器 rotate 单飞续期后重放(手册 G5);续不上则强登出回登录页

⚠️ 坑位

  • 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

即不轮询、不手动置状态、不本地乐观更新 —— 状态只有一个来源(后端四态)。详见 授权中心四态。

失败时的前端动作

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

码值与触发条件见 手册 · 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。

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。


三、健康数据 action ​

myTodayHealth(页面直调 · 首页健康卡) ​

一句话:首页 loadToday() 在 onShow 时拉一次 —— 一屏给齐步数 / 活动 / 心率 / 血氧 / 睡眠(块状结构);「目标值」由 stats · getTodayOverview 提供。

前端函数签名

项值
封装页面直调(未收敛)—— 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。

消费清单(页面实际读了什么)

读法用途
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 · 失败时的前端动作。

已实现、前端零调用的 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)
listDeviceDailyActivityvendor? / days?(缺省按 30 天){list: [...], days} —— 元素:id vendor date step distanceM caloriesKcal sleepMin activeMin source
myMetricsmetricType(必填,待确认取值域:源码注释列 HR / STRESS / VO2MAX(honor)、HRV / REST_HR(zepp)等)/ start? / end?{list: [...], total} —— 元素:id userId metricType calendarDate value sport level ingestedAt
mySleepstart? / 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 同一原则(token 绑 X-Env,跨库即失效)。
  • 失败先看 HTTP 状态:device 段是 HTTP 400/401/404 + code(不是 OAuth 的 HTTP 200 那一套)—— 拦截器按 statusCode 分档, 业务码是字符串,比较前 String(body?.code)。
  • 数据缺失是常态,不是错误:未绑定 / 未同步 ⇒ 各卡块为 null、sources 为 {} —— 前端一律 ?? 默认值 容忍,不要弹错误提示。

待确认(未从矩阵确证,勿当契约用) ​

#条目卡在哪
1listDeviceDailyActivity / myMetrics / mySleep 的字段与取值域不在 30 端点矩阵内(无前端封装 ⇒ 提取器盲区未补录);上表键名取自后端源码,未经矩阵审计
2myMetrics.metricType 的完整合法取值仅确认源码注释所列(HR/STRESS/VO2MAX/HRV/REST_HR…),是否有白名单校验未核
3「myTodayHealth 上线前曾 404」的运维事实静态源码无法证实/证伪(该 action 分支现存在)—— 保留但不可静态核
4health.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)
sleepDetaildate?(YYYY-MM-DD)summaries{day,week,month} + dayMetrics[] + stages[] + bedWake + weekRanges/monthRanges + periodMetrics
heartRateDetaildate?summaryMap{4 档} + metricsMap{4 档} + rangeSets{4 档} + restingSource
stressDetaildate?summaryMap/distributionMap/pointSets 四档 + card 可空
oxygenDetaildate?summaryMap/pointSets 四档 + overview/card 可空 —— card.series24 v2.0.65 起已解禁(raw.samples 逐时读路径:模板样本 + 小米逐样本)
heartRateDetail(补)date?todaySeries 逐时序列(honor dynamicHeartRate / 小米 bpm 双候选键)
stepsDetaildate?day/week/month 三档 ActivityBlock(无 hour/year)
caloriesDetaildate?同 stepsDetail 形状
strengthDetaildate?同 stepsDetail 形状
weightDetaildate?current 块 + dataMap/fatMap/muscleMap 四档
activitySummarydate? + month?('YYYY-MM' 才返回 monthDays)goals/day/week/[monthDays?]
recordDatesdays?(1-366)days + dates[] 倒序(四表并集)

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