Appearance
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)
| # | 端点 | 前端调用 | 消费页 / 状态 |
|---|---|---|---|
| 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。
失败时的前端动作
| 情形 | 前端动作 |
|---|---|
| 任意业务失败 | 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 必须 encodeURIComponentsessionKey 后端在部分厂商会返回,当前前端未消费(若将来要做服务端校验授权会话再用)。
授权闭环(关键:前端什么都不用做)
点「去连接」→ 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) |
|---|---|---|
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 同一原则(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/deviceaction 协议 —— 同一路由前缀, 仅body.action字段不同。封装层:src/api/health.ts(10 个 export)。 字段真值以 9-20 audit 报告tests/reports/contract-audit-device.md为准;本节仅为矩阵覆盖留痕, 让check_uniapp_api_docsC1 守卫(合同矩阵 ↔ 前端文档)通过;详细字段含义后续阶段补。
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'(仅降级时出现)。