diff --git a/apps/miniprogram/native/pkg-veepoo/index.js b/apps/miniprogram/native/pkg-veepoo/index.js index a66516e..141c97e 100644 --- a/apps/miniprogram/native/pkg-veepoo/index.js +++ b/apps/miniprogram/native/pkg-veepoo/index.js @@ -15,11 +15,14 @@ const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk'); var SDK_EVENT_AUTH = 1; var SDK_EVENT_BATTERY = 2; +var SDK_EVENT_SLEEP = 4; +var SDK_EVENT_DAILY = 5; var SDK_EVENT_TEMPERATURE = 6; var SDK_EVENT_BLOOD_PRESSURE = 18; var SDK_EVENT_BLOOD_OXYGEN = 31; var SDK_EVENT_HEART_RATE = 51; var SDK_EVENT_PRESSURE = 58; +var SDK_EVENT_AUTO_TEST = 54; var MEASURE_TYPES = [ { type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE }, @@ -271,6 +274,10 @@ Page({ console.log('[veepoo-native] 认证成功,设备就绪'); this.setData({ phase: 'ready' }); veepooFeature.veepooReadElectricQuantityManager(); + + // 认证成功后自动读取 3 天睡眠数据 + 开启自动测量 + this._readSleepData(); + this._enableAutoMeasurement(); }, // ── SDK 事件路由 ── @@ -301,6 +308,25 @@ Page({ return; } + // 睡眠数据回调(type=4) + if (type === SDK_EVENT_SLEEP) { + this._handleSleepEvent(data); + return; + } + + // 日常数据回调(type=5) + if (type === SDK_EVENT_DAILY) { + // 日常数据用于历史同步,原生页面暂不处理 + return; + } + + // 自动测量配置回调(type=54) + if (type === SDK_EVENT_AUTO_TEST) { + // eslint-disable-next-line no-undef + console.log('[veepoo-native] 自动测量配置回调'); + return; + } + for (var i = 0; i < MEASURE_TYPES.length; i++) { if (MEASURE_TYPES[i].sdkType === type) { this._handleMeasureEvent(MEASURE_TYPES[i].type, data); @@ -516,4 +542,96 @@ Page({ break; } }, + + // ── 睡眠数据读取 ── + + _sleepResults: null, + _sleepDay: 0, + + _readSleepData: function () { + this._sleepResults = []; + this._sleepDay = 0; + // eslint-disable-next-line no-undef + console.log('[veepoo-native] 开始读取睡眠数据(3天)'); + + // 依次读取 3 天睡眠 + var self = this; + veepooFeature.veepooSendReadPreciseSleepManager({ day: 0 }); + + // 延迟读取后续天(避免并发冲突) + // eslint-disable-next-line no-undef + setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 1 }); }, 3000); + // eslint-disable-next-line no-undef + setTimeout(function () { veepooFeature.veepooSendReadPreciseSleepManager({ day: 2 }); }, 6000); + }, + + _handleSleepEvent: function (data) { + var progress = data.Progress || 0; + if (progress < 100) return; + + var content = data.content || {}; + var readDay = data.readDay || 0; + var totalTime = Number(content.sleepTotalTime || 0); + + if (totalTime <= 0) return; + + var sleepResult = { + day: readDay, + deepSleepMinutes: Number(content.deepSleepTime || 0), + lightSleepMinutes: Number(content.lightSleepTime || 0), + totalSleepMinutes: totalTime, + qualityScore: Number(content.sleepQuality || 0), + fallAsleepTime: String(content.fallAsleepTime || ''), + exitSleepTime: String(content.exitSleepTime || ''), + }; + + if (!this._sleepResults) this._sleepResults = []; + this._sleepResults.push(sleepResult); + + // eslint-disable-next-line no-undef + console.log('[veepoo-native] 睡眠数据 day=' + readDay + ' 总时长=' + totalTime + '分钟 质量=' + sleepResult.qualityScore + '星'); + + // 保存到 Storage 供 Taro 页面读取 + try { + // eslint-disable-next-line no-undef + wx.setStorageSync('hms:veepoo_sleep_results', JSON.stringify(this._sleepResults)); + } catch (_) { /* ignore */ } + }, + + // ── 自动测量 ── + + _enableAutoMeasurement: function () { + // eslint-disable-next-line no-undef + console.log('[veepoo-native] 开启自动测量功能'); + + // 开启心率自动监测 + try { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticHRTest: 'open', + }); + } catch (e) { + // eslint-disable-next-line no-undef + console.warn('[veepoo-native] 开启心率自动监测失败:', e); + } + + // 开启血压自动监测 + try { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticBPTest: 'open', + }); + } catch (e) { + // eslint-disable-next-line no-undef + console.warn('[veepoo-native] 开启血压自动监测失败:', e); + } + + // 开启体温自动监测 + try { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticTemperatureTest: 'open', + }); + } catch (e) { + // eslint-disable-next-line no-undef + console.warn('[veepoo-native] 开启体温自动监测失败:', e); + } + }, }); diff --git a/apps/miniprogram/native/pkg-veepoo/index.wxml b/apps/miniprogram/native/pkg-veepoo/index.wxml index 4568ac8..73f78b3 100644 --- a/apps/miniprogram/native/pkg-veepoo/index.wxml +++ b/apps/miniprogram/native/pkg-veepoo/index.wxml @@ -1,5 +1,6 @@ @@ -26,7 +27,7 @@ - 查看测量结果并返回 + 查看测量结果并返回 @@ -51,82 +52,92 @@ - - - - {{deviceName}} - {{batteryLevel}}% + + + + {{deviceName}} + {{batteryLevel}}% - 断开 + 断开 - - + + - {{item.icon}} - {{item.label}} - + + {{item.icon}} + + {{item.label}} - + - - - - - - {{selectedIcon}} - 点击下方按钮开始测量{{selectedLabel}} - - - - {{measureDisplayValue}} - 测量中... - {{selectedUnit}} - - - - {{measureDisplayValue}} - {{selectedUnit}} - - - - ! - {{measureError}} - + + + + + + + + + + + {{selectedIcon}} + 点击下方按钮开始 + + + + {{measureDisplayValue}} + 测量中... + {{selectedUnit}} + + + + {{measureDisplayValue}} + {{selectedUnit}} + + + + ! + {{measureError}} + + + + - - + + - 本数据由智能手环采集,仅供健康趋势参考,不作为医疗诊断依据 + 测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。 - 开始测量{{selectedLabel}} - - - 停止测量 - - - - 重新测量 - 完成并返回 + + 开始测量{{selectedLabel}} + + 停止测量 + 完成并查看结果 + + + 重新测量 + 完成并查看结果 + - 重新测量 + 重新测量 diff --git a/apps/miniprogram/native/pkg-veepoo/index.wxss b/apps/miniprogram/native/pkg-veepoo/index.wxss index 817f4ae..0ae178b 100644 --- a/apps/miniprogram/native/pkg-veepoo/index.wxss +++ b/apps/miniprogram/native/pkg-veepoo/index.wxss @@ -1,355 +1,462 @@ -/* Veepoo M2 原生页面样式 */ +/** + * Veepoo M2 原生页面样式 + * 设计原型: docs/design/veepoo-measure-prototype.html + * 复刻小程序 design token + */ page { - background: #F5F5F4; + --pri: #C4623A; + --pri-l: #F0DDD4; + --bg: #F5F0EB; + --card: #FFFFFF; + --tx: #2D2A26; + --tx2: #5A554F; + --tx3: #78716C; + --bd: #E8E2DC; + --acc: #5B7A5E; + --acc-l: #E8F0E8; + --dan: #B54A4A; + --dan-l: #FDEAEA; + background: var(--bg); min-height: 100vh; } -/* ═══ 连接屏幕 ═══ */ - +/* ═══════════════════════════════════════ + 连接页面(未连接/连接中/错误) + ═══════════════════════════════════════ */ .connect-screen { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; - padding: 40rpx; + padding: 0 40px; } .connect-anim { position: relative; - width: 200rpx; - height: 200rpx; - margin-bottom: 48rpx; + width: 120px; + height: 120px; + margin-bottom: 28px; } .connect-ring { position: absolute; - top: 0; left: 0; - width: 100%; height: 100%; + inset: 0; border-radius: 50%; - border: 4rpx solid #D6D3D1; + border: 3px solid var(--pri); + animation: pulse-ring 2s ease-out infinite; } .connect-ring--active { - border-color: #C4623A; - animation: pulse 1.5s ease-in-out infinite; -} - -@keyframes pulse { - 0%, 100% { transform: scale(1); opacity: 0.6; } - 50% { transform: scale(1.1); opacity: 1; } + border-color: var(--pri); + animation: pulse-ring 1.5s ease-out infinite; } .connect-center { position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - width: 80rpx; height: 80rpx; + inset: 20px; border-radius: 50%; - background: #292524; + background: var(--pri); display: flex; align-items: center; justify-content: center; } .connect-bt { - color: #FAFAF9; - font-size: 28rpx; - font-weight: 600; + color: #fff; + font-size: 20px; + font-weight: 700; } .connect-title { - font-size: 36rpx; - font-weight: 600; - color: #1C1917; - margin-bottom: 16rpx; + font-family: Georgia, 'Times New Roman', serif; + font-size: 22px; + font-weight: 700; + color: var(--tx); + margin-bottom: 8px; + line-height: 1.3; } .connect-hint { - font-size: 26rpx; - color: #78716C; - margin-bottom: 48rpx; + font-size: 14px; + color: var(--tx3); + margin-bottom: 32px; + text-align: center; } .connect-error { - background: #FEF2F2; - border-radius: 16rpx; - padding: 24rpx 32rpx; - margin-bottom: 32rpx; - width: 100%; - max-width: 600rpx; + margin-bottom: 16px; + text-align: center; } .connect-error-text { - font-size: 26rpx; - color: #DC2626; + font-size: 14px; + color: var(--dan); } .connect-btn-wrap { - width: 100%; - max-width: 600rpx; + width: 200px; } .connect-back { - width: 100%; - max-width: 600rpx; - margin-top: 24rpx; + margin-top: 16px; } -/* ═══ 按钮 ═══ */ - -.btn-primary { - background: #C4623A; - color: #FFFFFF; - font-size: 30rpx; - font-weight: 600; - text-align: center; - padding: 24rpx 0; - border-radius: 16rpx; +@keyframes pulse-ring { + 0% { transform: scale(1); opacity: 1; } + 100% { transform: scale(1.4); opacity: 0; } } -.btn-secondary { - background: #FFFFFF; - color: #44403C; - font-size: 30rpx; - font-weight: 500; - text-align: center; - padding: 24rpx 0; - border-radius: 16rpx; - border: 2rpx solid #D6D3D1; -} - -.btn-large { - width: 100%; - max-width: 600rpx; -} - -.actions-row { - display: flex; - gap: 24rpx; - width: 100%; - max-width: 600rpx; -} - -.actions-row .btn-secondary, -.actions-row .btn-primary { - flex: 1; -} - -/* ═══ 测量页面 ═══ */ - +/* ═══════════════════════════════════════ + 测量页面(就绪态) + ═══════════════════════════════════════ */ .measure-page { - padding: 24rpx; - display: flex; - flex-direction: column; min-height: 100vh; + padding-bottom: 40px; } -/* ── Header ── */ - -.header { +/* ── 设备状态栏 ── */ +.device-bar { display: flex; + align-items: center; justify-content: space-between; - align-items: center; - padding: 16rpx 24rpx; - background: #FFFFFF; - border-radius: 16rpx; - margin-bottom: 24rpx; + padding: 10px 20px; + background: var(--card); + border-bottom: 1px solid var(--bd); } -.header-device { +.device-bar__left { display: flex; align-items: center; - gap: 12rpx; + gap: 8px; } -.header-dot { - width: 16rpx; height: 16rpx; +.device-bar__dot { + width: 8px; + height: 8px; border-radius: 50%; - background: #A8A29E; + background: var(--acc); } -.header-dot--on { - background: #22C55E; -} - -.header-name { - font-size: 28rpx; +.device-bar__name { + font-size: 16px; font-weight: 600; - color: #1C1917; + color: var(--tx); } -.header-battery { - font-size: 24rpx; - color: #78716C; +.device-bar__battery { + font-size: 13px; + color: var(--tx3); + margin-left: 4px; } -.header-disconnect { - font-size: 26rpx; - color: #C4623A; +.device-bar__disconnect { + font-size: 13px; + color: var(--tx3); + padding: 6px 12px; + background: transparent; + border: 1px solid var(--bd); + border-radius: 999px; } -/* ── Selector ── */ - +/* ── 指标选择器(药丸式) ── */ .selector { display: flex; - justify-content: space-around; - padding: 24rpx 0; - background: #FFFFFF; - border-radius: 16rpx; - margin-bottom: 24rpx; + white-space: nowrap; + padding: 16px 20px; + gap: 8px; } -.selector-item { +.selector__pill { + flex-shrink: 0; + display: inline-flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 14px; + border-radius: 16px; + position: relative; + min-width: 64px; + transition: all 200ms ease; +} + +.selector__pill--active { + background: var(--card); + box-shadow: 0 2px 12px rgba(45,42,38,0.10); +} + +.selector__pill--done::after { + content: '✓'; + position: absolute; + top: 4px; + right: 6px; + font-size: 10px; + color: #fff; + background: var(--acc); + width: 16px; + height: 16px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.selector__icon-wrap { + width: 36px; + height: 36px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + transition: transform 200ms ease; +} + +.selector__pill--active .selector__icon-wrap { + transform: scale(1.15); +} + +.selector__icon { + font-size: 18px; + color: #fff; +} + +.selector__label { + font-size: 13px; + color: var(--tx3); +} + +.selector__pill--active .selector__label { + color: var(--tx); + font-weight: 600; +} + +/* ── 仪表盘 ── */ +.gauge-section { display: flex; flex-direction: column; align-items: center; - gap: 8rpx; - padding: 12rpx 16rpx; - border-radius: 12rpx; + padding: 16px 0 24px; +} + +.gauge { position: relative; } -.selector-item--active { - background: #FFF7ED; +.gauge--measuring { + animation: gauge-breathe 2s ease-in-out infinite; } -.selector-item--done::after { - content: ''; +@keyframes gauge-breathe { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.03); } } -.selector-icon { - font-size: 40rpx; +.gauge__ring-wrap { + position: relative; + width: 220px; + height: 220px; } -.selector-label { - font-size: 22rpx; - color: #57534E; -} - -.selector-check { +.gauge__ring-bg { position: absolute; - top: 4rpx; right: 4rpx; - width: 28rpx; height: 28rpx; + inset: 0; border-radius: 50%; - color: #FFFFFF; - font-size: 18rpx; - display: flex; - align-items: center; - justify-content: center; + border: 10px solid var(--bd); + box-sizing: border-box; } -/* ── Gauge ── */ +.gauge__ring-progress { + position: absolute; + inset: 0; + border-radius: 50%; +} -.gauge { - flex: 1; +.gauge__center { + position: absolute; + inset: 20px; display: flex; flex-direction: column; align-items: center; justify-content: center; - padding: 48rpx 0; } -.gauge-circle { - width: 400rpx; - height: 400rpx; - border-radius: 50%; - background: #FFFFFF; - border: 8rpx solid #E7E5E4; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - margin-bottom: 24rpx; +.gauge__icon-lg { + font-size: 40px; + margin-bottom: 8px; } -.gauge-icon { - font-size: 64rpx; - margin-bottom: 16rpx; +.gauge__hint { + font-size: 13px; + color: var(--tx3); + text-align: center; } -.gauge-hint { - font-size: 26rpx; - color: #78716C; -} - -.gauge-value { - font-size: 80rpx; +.gauge__value { + font-family: Georgia, 'Times New Roman', serif; + font-size: 52px; font-weight: 700; - line-height: 1.1; + line-height: 1; } -.gauge-loading { - font-size: 30rpx; - color: #78716C; +.gauge__unit { + font-size: 14px; + color: var(--tx3); + margin-top: 4px; } -.gauge-err { - font-size: 72rpx; +.gauge__loading { + font-size: 16px; + color: var(--tx2); +} + +.gauge__err { + font-size: 36px; + color: var(--dan); font-weight: 700; - color: #DC2626; - margin-bottom: 8rpx; } -.gauge-err-text { - font-size: 26rpx; - color: #DC2626; +.gauge__err-text { + font-size: 13px; + color: var(--tx2); + text-align: center; } -.gauge-progress-bar { - width: 500rpx; - height: 8rpx; - background: #E7E5E4; - border-radius: 4rpx; +/* ── 进度条 ── */ +.progress-bar { + width: 240px; + height: 4px; + background: var(--bd); + border-radius: 2px; + margin-top: 16px; overflow: hidden; } -.gauge-progress-fill { +.progress-bar__fill { height: 100%; - background: #C4623A; - border-radius: 4rpx; - transition: width 0.3s ease; + border-radius: 2px; + transition: width 0.3s ease-out; } -/* ── Assessment ── */ - -.assessment { - text-align: center; - padding: 16rpx; -} - -.assessment-text { - font-size: 26rpx; - color: #16A34A; -} - -/* ── Disclaimer ── */ - +/* ── 免责声明 ── */ .disclaimer { text-align: center; - padding: 16rpx 32rpx; + padding: 0 20px; + margin-bottom: 16px; } -.disclaimer-text { - font-size: 22rpx; - color: #A8A29E; +.disclaimer__text { + font-size: 11px; + color: var(--tx3); + line-height: 1.5; } -/* ── Actions ── */ - +/* ── 操作按钮 ── */ .actions { - padding: 24rpx 0; + padding: 0 20px; + display: flex; + flex-direction: column; + gap: 12px; } -/* ── Measure Error ── */ - -.measure-error { +.btn { + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; text-align: center; - padding: 16rpx; + transition: opacity 150ms; } -.measure-error-text { - font-size: 26rpx; - color: #DC2626; +.btn:active { + opacity: 0.85; } + +.btn--primary { + background: var(--pri); + color: #fff; + box-shadow: 0 4px 16px rgba(196,98,58,0.3); +} + +.btn--secondary { + background: var(--card); + color: var(--tx); + border: 1px solid var(--bd); +} + +.btn--text { + background: transparent; + color: var(--tx3); + height: 44px; + font-size: 14px; +} + +/* ═══ 旧版兼容样式 ═══ */ +.btn-primary { + background: var(--pri); + color: #fff; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + box-shadow: 0 4px 16px rgba(196,98,58,0.3); +} +.btn-primary:active { opacity: 0.85; } + +.btn-secondary { + background: var(--card); + color: var(--tx); + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + font-weight: 600; + border: 1px solid var(--bd); +} +.btn-secondary:active { opacity: 0.85; } + +.btn-text { + background: transparent; + color: var(--tx3); + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; +} + +.btn-large { margin: 0; } + +/* 旧版 header/selector/gauge 兼容 */ +.header { display: none; } +.header-device { display: none; } +.header-dot { display: none; } +.header-name { display: none; } +.header-battery { display: none; } +.header-disconnect { display: none; } +.selector-item { display: none; } +.selector-icon { display: none; } +.selector-label { display: none; } +.selector-check { display: none; } +.gauge-circle { display: none; } +.gauge-icon { display: none; } +.gauge-hint { display: none; } +.gauge-value { display: none; } +.gauge-loading { display: none; } +.gauge-err { display: none; } +.gauge-err-text { display: none; } +.gauge-progress-bar { display: none; } +.gauge-progress-fill { display: none; } +.assessment { display: none; } +.assessment-text { display: none; } +.disclaimer-text { display: none; } +.measure-error { display: none; } +.measure-error-text { display: none; } diff --git a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss index ce116d2..fbd7fd8 100644 --- a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss +++ b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss @@ -1,10 +1,13 @@ -// Veepoo 实时测量页样式 +// Veepoo 测量结果 + 上传页样式 +// 设计原型: docs/design/veepoo-measure-prototype.html +@import '../../../styles/variables.scss'; + .vm-page { min-height: 100vh; - background: var(--tk-bg-primary); + background: var(--tk-bg-primary, $bg); } -// ── 连接中 ── +// ── 连接中(等待跳转态) ── .vm-connect { display: flex; flex-direction: column; @@ -24,7 +27,7 @@ position: absolute; inset: 0; border-radius: 50%; - border: 3px solid var(--tk-brand, #3B82F6); + border: 3px solid $pri; animation: vm-pulse-ring 2s ease-out infinite; } @@ -32,7 +35,7 @@ position: absolute; inset: 20px; border-radius: 50%; - background: var(--tk-brand, #3B82F6); + background: $pri; display: flex; align-items: center; justify-content: center; @@ -45,33 +48,16 @@ } &__title { - font-size: 18px; - font-weight: 600; - color: var(--tk-text-primary); + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-h2, 22px); + font-weight: 700; + color: $tx; margin-bottom: 8px; } &__hint { - font-size: 14px; - color: var(--tk-text-tertiary); - margin-bottom: 24px; - } - - &__error { - margin-top: 16px; - text-align: center; - } - - &__error-text { - font-size: 14px; - color: var(--tk-color-danger, #EF4444); - display: block; - margin-bottom: 16px; - } - - &__error-btn { - width: 200px; - margin: 0 auto; + font-size: var(--tk-font-body-sm, 14px); + color: $tx3; } } @@ -80,319 +66,191 @@ 100% { transform: scale(1.4); opacity: 0; } } -// ── 就绪/测量中 ── -.vm-body { - padding: 16px; -} +// ── 上传页面 ── +.vm-upload { + min-height: 100vh; + padding-bottom: 40px; -// ── 设备状态栏 ── -.vm-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - background: var(--tk-bg-secondary); - border-radius: 12px; - margin-bottom: 16px; - - &__device { - display: flex; - align-items: center; - gap: 8px; + &__header { + padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px); } - &__dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--tk-color-success, #10B981); - - &--on { background: var(--tk-color-success, #10B981); } + &__title { + display: block; + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-h2, 22px); + font-weight: 700; + color: $tx; + line-height: 1.3; } - &__name { - font-size: 14px; - font-weight: 500; - color: var(--tk-text-primary); - } - - &__battery { - font-size: 12px; - color: var(--tk-text-tertiary); - } - - &__disconnect { - font-size: 13px; - color: var(--tk-text-tertiary); - padding: 4px 8px; + &__subtitle { + display: block; + font-size: var(--tk-font-body-sm, 14px); + color: $tx3; + margin-top: 4px; } } -// ── 指标选择器 ── -.vm-selector { +// ── 结果卡片网格 ── +.vm-results-grid { + padding: 0 var(--tk-page-padding, 20px); display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 8px; - margin-bottom: 24px; + grid-template-columns: repeat(2, 1fr); + gap: var(--tk-gap-sm, 12px); +} - &__item { - display: flex; - flex-direction: column; - align-items: center; - padding: 12px 4px; - border-radius: 12px; - border: 2px solid transparent; - background: var(--tk-bg-secondary); - position: relative; - transition: all 0.2s; +.vm-result-card { + background: $card; + border-radius: var(--tk-card-radius, 16px); + padding: var(--tk-gap-md, 16px); + box-shadow: $shadow-sm; + position: relative; + overflow: hidden; - &--active { - border-color: var(--tk-brand, #3B82F6); - background: var(--tk-bg-tertiary); - } - - &--measuring { - opacity: 0.7; - } - - &--done { - border-color: var(--tk-color-success, #10B981); - } + &--full { + grid-column: 1 / -1; } - &__icon { - font-size: 22px; - margin-bottom: 4px; + &--empty { + opacity: 0.5; + } + + &__badge { + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + border-radius: 0 4px 4px 0; } &__label { - font-size: 12px; - color: var(--tk-text-secondary); + display: block; + font-size: var(--tk-font-cap, 13px); + color: $tx2; + margin-bottom: 8px; + padding-left: 8px; } - &__check { - position: absolute; - top: 4px; - right: 4px; - width: 16px; - height: 16px; - border-radius: 50%; - color: #fff; - font-size: 10px; + &__row { display: flex; - align-items: center; - justify-content: center; - } -} - -// ── 仪表盘 ── -.vm-gauge { - display: flex; - justify-content: center; - padding: 16px 0; - - &__ring { - position: relative; - width: 220px; - height: 220px; - - &--measuring { - animation: vm-gauge-breathe 2s ease-in-out infinite; - } - } - - &__svg { - width: 100%; - height: 100%; - } - - &__progress { - transition: stroke-dasharray 0.3s ease-out; - } - - &__center { - position: absolute; - inset: 20px; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - } - - &__idle { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; - } - - &__icon { - font-size: 40px; - } - - &__hint { - font-size: 13px; - color: var(--tk-text-tertiary); - } - - &__measuring, &__success { - display: flex; - flex-direction: column; - align-items: center; + align-items: baseline; + gap: 4px; + padding-left: 8px; } &__value { - font-size: 48px; + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-num-lg, 34px); font-weight: 700; + color: $tx; line-height: 1; } &__unit { - font-size: 14px; - color: var(--tk-text-secondary); - margin-top: 4px; + font-size: var(--tk-font-cap, 13px); + color: $tx3; } - &__loading { - font-size: 16px; - color: var(--tk-text-secondary); - } - - &__error { - display: flex; - flex-direction: column; + &__tag { + display: inline-flex; align-items: center; - gap: 8px; - } - - &__err-icon { - font-size: 36px; - color: var(--tk-color-danger, #EF4444); - font-weight: 700; - } - - &__err-text { - font-size: 13px; - color: var(--tk-text-secondary); - text-align: center; - } -} - -@keyframes vm-gauge-breathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.03); } -} - -// ── 健康评估 ── -.vm-assessment { - text-align: center; - padding: 12px; - border-radius: 8px; - margin: 0 16px 16px; - - &--normal { - background: #ECFDF5; - color: #059669; - } - - &--warning { - background: #FFFBEB; - color: #D97706; - } - - &--danger { - background: #FEF2F2; - color: #DC2626; - } - - &__text { - font-size: 14px; + gap: 3px; + margin-top: 8px; + margin-left: 8px; + padding: 2px 8px; + border-radius: 999px; + font-size: var(--tk-font-micro, 11px); font-weight: 500; + + &--normal { + background: $acc-l; + color: $acc; + } + + &--warning { + background: $wrn-l; + color: $wrn; + } + + &--danger { + background: $dan-l; + color: $dan; + } + } + + &__placeholder { + padding-left: 8px; + font-size: var(--tk-font-body-sm, 14px); + color: $tx3; + } + + &--sleep { + padding-bottom: 12px; } } -// ── 免责声明 ── -.vm-disclaimer { - text-align: center; - padding: 8px 16px; - margin-bottom: 16px; +// ── 睡眠数据行 ── +.vm-sleep-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px; + margin-left: 8px; - &__text { - font-size: 11px; - color: var(--tk-text-quaternary); - line-height: 1.5; + &__day { + font-size: var(--tk-font-body-sm, 14px); + color: $tx2; + min-width: 40px; + } + + &__time { + font-family: Georgia, 'Times New Roman', serif; + font-size: var(--tk-font-body, 16px); + font-weight: 600; + color: $tx; + } + + &__quality { + font-size: 12px; + color: $wrn; + margin-left: auto; } } -// ── 操作按钮 ── -.vm-actions { - padding: 0 16px; +// ── 底部上传播区 ── +.vm-upload-footer { + padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px); - &__row { - display: flex; - gap: 12px; + &__hint { + display: block; + font-size: var(--tk-font-cap, 13px); + color: $tx3; + text-align: center; + margin-bottom: var(--tk-gap-sm, 12px); } -} -// ── 测量错误 ── -.vm-measure-error { - text-align: center; - padding: 8px 16px; - margin-top: 12px; + &__btn { + width: 100%; + } - &__text { - font-size: 13px; - color: var(--tk-color-danger, #EF4444); + &__time { + display: block; + font-size: var(--tk-font-micro, 11px); + color: $tx3; + text-align: center; + margin-top: var(--tk-gap-sm, 12px); } } // ── 长者模式 ── .elder-mode { - .vm-selector { - grid-template-columns: repeat(3, 1fr); - gap: 12px; + .vm-results-grid { + grid-template-columns: 1fr; } - .vm-selector__item { - padding: 16px 8px; - } - - .vm-selector__icon { - font-size: 28px; - } - - .vm-selector__label { - font-size: 16px; - } - - .vm-gauge { - &__ring { - width: 260px; - height: 260px; - } - - &__value { - font-size: 64px; - } - - &__unit { - font-size: 18px; - } - - &__hint { - font-size: 16px; - } - } - - .vm-header__name { - font-size: 16px; - } - - .vm-disclaimer__text { - font-size: 14px; - } - - .vm-assessment__text { - font-size: 16px; + .vm-result-card__value { + font-size: var(--tk-font-num-lg, 40px); } } diff --git a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx index 30458d7..7858a12 100644 --- a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx @@ -1,6 +1,6 @@ import React, { useRef } from 'react'; import { View, Text } from '@tarojs/components'; -import Taro, { useDidShow } from '@tarojs/taro'; +import Taro, { useDidShow, useRouter } from '@tarojs/taro'; import { useAuthStore } from '@/stores/auth'; import { uploadReadings } from '@/services/device-sync'; import type { NormalizedReading } from '@/services/ble/types'; @@ -16,17 +16,89 @@ interface NativeMeasureResult { measuredAt: number; } +/** 原生页面返回的睡眠数据格式 */ +interface NativeSleepResult { + day: number; + deepSleepMinutes: number; + lightSleepMinutes: number; + totalSleepMinutes: number; + qualityScore: number; + fallAsleepTime: string; + exitSleepTime: string; +} + +/** 指标配置 */ +const METRIC_CONFIG = [ + { type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' }, + { type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' }, + { type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' }, + { type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' }, + { type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' }, +] as const; + +/** 健康评估 */ +function assessHealth(type: string, values: Record): { level: 'normal' | 'warning' | 'danger'; text: string } { + switch (type) { + case 'heart_rate': { + const v = values.heart_rate ?? 0; + if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' }; + if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' }; + return { level: 'warning', text: '心率偏离正常范围' }; + } + case 'blood_oxygen': { + const v = values.blood_oxygen ?? 0; + if (v >= 95) return { level: 'normal', text: '血氧正常' }; + if (v >= 90) return { level: 'warning', text: '血氧偏低' }; + return { level: 'danger', text: '血氧过低' }; + } + case 'blood_pressure': { + const sys = values.systolic ?? 0; + const dia = values.diastolic ?? 0; + if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' }; + if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' }; + return { level: 'warning', text: '血压偏高' }; + } + case 'temperature': { + const v = values.temperature ?? 0; + if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' }; + if (v > 38.0) return { level: 'danger', text: '发热' }; + return { level: 'warning', text: '体温偏离正常' }; + } + case 'pressure': { + const v = values.pressure ?? 0; + if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' }; + if (v > 60) return { level: 'danger', text: '压力过高' }; + return { level: 'warning', text: '压力偏高' }; + } + default: + return { level: 'normal', text: '' }; + } +} + +/** 格式化显示值 */ +function formatValue(type: string, values: Record): string { + if (type === 'blood_pressure') { + return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`; + } + const v = Object.values(values)[0]; + return v !== undefined ? String(v) : '--'; +} + export default function VeepooMeasure() { const modeClass = useElderClass(); + const router = useRouter(); const patient = useAuthStore((s) => s.currentPatient); const navigatedRef = useRef(false); const [results, setResults] = React.useState>({}); - const [uploadStatus, setUploadStatus] = React.useState(''); + const [sleepData, setSleepData] = React.useState([]); + const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle'); + + // 从 URL 或 store 获取 patientId + const patientId = patient?.id || router.params.patientId || ''; // C3 修复:用 ref 防重入,避免 React Strict Mode 双触发 if (!navigatedRef.current) { navigatedRef.current = true; - const patientId = patient?.id || ''; // 延迟到下一个微任务,确保页面渲染完成后再跳转 setTimeout(() => { Taro.navigateTo({ @@ -43,7 +115,7 @@ export default function VeepooMeasure() { }, 50); } - // 页面恢复时读取原生页面返回的测量结果 + // 页面恢复时读取原生页面返回的测量结果 + 睡眠数据 useDidShow(() => { try { const raw = Taro.getStorageSync('hms:veepoo_measure_results'); @@ -53,58 +125,182 @@ export default function VeepooMeasure() { Taro.removeStorageSync('hms:veepoo_measure_results'); } } catch { /* ignore */ } + + try { + const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results'); + if (rawSleep) { + const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[]; + if (parsedSleep.length > 0) { + setSleepData(parsedSleep); + } + Taro.removeStorageSync('hms:veepoo_sleep_results'); + } + } catch { /* ignore */ } }); const handleUpload = async () => { - if (!patient) return; + // 修复:添加明确的错误提示,不再静默退出 + if (!patientId) { + console.warn('[veepoo-measure] 上传失败:未获取到患者 ID'); + Taro.showToast({ title: '请先绑定患者档案', icon: 'none' }); + return; + } const allResults = Object.values(results); - if (allResults.length === 0) return; + const hasMeasureData = allResults.length > 0; + const hasSleep = sleepData.length > 0; - setUploadStatus('上传中...'); + if (!hasMeasureData && !hasSleep) { + console.warn('[veepoo-measure] 上传失败:无数据'); + Taro.showToast({ title: '暂无测量数据', icon: 'none' }); + return; + } + + setUploadStatus('uploading'); try { - // C2 修复:使用 uploadReadings,类型与 NormalizedReading 对齐 - const readings: NormalizedReading[] = allResults.map((r) => ({ - device_type: r.type as NormalizedReading['device_type'], - values: r.values, - measured_at: new Date(r.measuredAt).toISOString(), - })); - await uploadReadings(patient.id, 'veepoo_m2', 'Veepoo M2', readings); - setUploadStatus('上传成功'); + const allReadings: NormalizedReading[] = []; + + // 测量结果 + if (hasMeasureData) { + console.log('[veepoo-measure] 上传测量数据', allResults.length, '项'); + allReadings.push(...allResults.map((r) => ({ + device_type: r.type as NormalizedReading['device_type'], + values: r.values, + measured_at: new Date(r.measuredAt).toISOString(), + }))); + } + + // 睡眠数据 + if (hasSleep) { + const now = new Date(); + console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天'); + allReadings.push(...sleepData.map((s) => { + const baseDate = new Date(now.getTime() - s.day * 86400000); + return { + device_type: 'sleep' as const, + values: { + deep_sleep_minutes: s.deepSleepMinutes, + light_sleep_minutes: s.lightSleepMinutes, + total_sleep_minutes: s.totalSleepMinutes, + quality_score: s.qualityScore, + }, + measured_at: baseDate.toISOString(), + }; + })); + } + + await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings); + setUploadStatus('success'); Taro.showToast({ title: '数据已上传', icon: 'success' }); - } catch { - setUploadStatus('上传失败'); - Taro.showToast({ title: '上传失败', icon: 'none' }); + } catch (err) { + console.error('[veepoo-measure] 上传失败:', err); + setUploadStatus('error'); + Taro.showToast({ title: '上传失败,请重试', icon: 'none' }); } }; const hasResults = Object.keys(results).length > 0; + const measuredCount = Object.keys(results).length; + const measuredAt = hasResults + ? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) + : ''; return ( - - - - BT - - M2 手环健康测量 - - {hasResults ? ( - - {Object.entries(results).map(([type, r]) => ( - - {type} - {JSON.stringify(r.values)} - - ))} - {uploadStatus} - - 上传测量数据 - + {hasResults ? ( + + {/* 页面标题 */} + + 测量结果 + Veepoo M2 · 刚刚完成测量 - ) : ( + + {/* 结果卡片网格 */} + + {METRIC_CONFIG.map((metric) => { + const result = results[metric.type]; + if (result) { + const assessment = assessHealth(metric.type, result.values); + return ( + + + {metric.label} + + {formatValue(metric.type, result.values)} + {metric.unit} + + + ● {assessment.text} + + + ); + } + // 未测量占位 + return ( + + + {metric.label} + 未测量 + + ); + })} + + {/* 睡眠数据卡片 */} + {sleepData.length > 0 && ( + + + 睡眠数据({sleepData.length} 天) + {sleepData.map((sleep, idx) => { + const hours = Math.floor(sleep.totalSleepMinutes / 60); + const mins = sleep.totalSleepMinutes % 60; + const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚'; + return ( + + {dayLabel} + {hours}h{mins > 0 ? ` ${mins}min` : ''} + + {'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))} + + + ); + })} + + ● 自动同步 + + + )} + + + {/* 底部上传播区 */} + + 测量数据将上传至您的健康档案 + + + {uploadStatus === 'uploading' + ? '上传中...' + : uploadStatus === 'success' + ? '✓ 已上传' + : `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''})`} + + + {measuredAt && 测量时间:{measuredAt}} + + + ) : ( + + + + BT + + M2 手环健康测量 即将跳转到设备测量页面... - )} - + + )} ); } diff --git a/apps/miniprogram/src/services/ble/VeepooBridge.ts b/apps/miniprogram/src/services/ble/VeepooBridge.ts new file mode 100644 index 0000000..72d02a0 --- /dev/null +++ b/apps/miniprogram/src/services/ble/VeepooBridge.ts @@ -0,0 +1,305 @@ +/** + * Veepoo SDK 桥接模块 + * + * 调用顺序(基于 SDK Demo 验证): + * 1. startScan() — 初始化蓝牙 + 扫描 + * 2. stopScan() — 找到设备后停止扫描 + * 3. connectDevice(deviceObj) — 传入完整设备对象(非 deviceId 字符串) + * 4. registerDataListener() — 连接成功后注册数据监听 + * 5. authenticate() — 延迟 500ms 后调用秘钥认证 + * 6. 认证结果通过数据监听回调 type=1 返回 + */ + +// @ts-ignore — SDK 类型声明为 any +import { veepooBle, veepooFeature, veepooLogger } from './veepoo-sdk'; + +// ── SDK 事件类型常量 ── + +/** 秘钥认证结果 */ +export const SDK_EVENT_AUTH = 1; +/** 日常数据 */ +export const SDK_EVENT_DAILY = 5; +/** 体温检测 */ +export const SDK_EVENT_TEMPERATURE = 6; +/** 血压 */ +export const SDK_EVENT_BLOOD_PRESSURE = 18; +/** 血氧手动测量 */ +export const SDK_EVENT_BLOOD_OXYGEN = 31; +/** 心率测量 */ +export const SDK_EVENT_HEART_RATE = 51; +/** 压力测量 */ +export const SDK_EVENT_PRESSURE = 58; + +/** 设备正忙状态枚举(SDK state 字段) */ +export const DEVICE_STATE = { + IDLE: 0, + MEASURING_BP: 1, + MEASURING_HR: 2, + AUTO_TEST: 3, + MEASURING_SPO2: 4, + MEASURING_FATIGUE: 5, + NOT_WORN: 6, + CHARGING: 7, + LOW_BATTERY: 8, + BUSY: 9, +} as const; + +/** 连接回调中 connection 字段为 true 表示连接成功 */ +export interface VeepooConnectionResult { + connection?: boolean; + errno?: number; + errCode?: number; + errMsg?: string; +} + +/** SDK 事件回调数据(统一格式) */ +export interface SdkEventData { + name: string; + type: number; + content: Record; + Progress?: number; + state?: number; + control?: number; + ack?: number; +} + +// ── 蓝牙模块 ── + +/** 初始化蓝牙 + 开始扫描 */ +export function startScan(onDeviceFound: (device: unknown) => void): void { + veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice( + (res: unknown) => { + const device = Array.isArray(res) ? res[0] : res; + if (device) onDeviceFound(device); + }, + ); +} + +/** 停止扫描 */ +export function stopScan(): Promise { + return new Promise((resolve) => { + veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve()); + }); +} + +/** 连接设备 — 传入完整设备对象 */ +export function connectDevice(device: unknown): Promise { + return new Promise((resolve) => { + veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager( + device, + (res: VeepooConnectionResult) => resolve(res), + ); + }); +} + +/** 注册数据监听(必须在连接成功后调用) */ +export function registerDataListener(callback: (data: SdkEventData) => void): void { + veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(callback); +} + +/** 监听蓝牙连接状态变化 */ +export function registerConnectionListener(callback: (res: { deviceId: string; connected: boolean }) => void): void { + veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(callback); +} + +/** 断开连接 */ +export function disconnect(): Promise { + return new Promise((resolve) => { + veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(() => resolve()); + }); +} + +// ── 功能模块:认证 ── + +/** 秘钥认证(无参数无回调,结果通过数据监听 type=1 返回) */ +export function authenticate(): void { + veepooFeature.veepooBlePasswordCheckManager(); +} + +// ── 功能模块:测量指令 ── + +/** 心率测量开关(true=开启,false=关闭) */ +export function setHeartRateMeasure(on: boolean): void { + veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: on }); +} + +/** 血氧测量开关('start'=开启,'stop'=关闭) */ +export function setBloodOxygenMeasure(action: 'start' | 'stop'): void { + veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: action }); +} + +/** 血压测量开关('start'=开启,'stop'=关闭) */ +export function setBloodPressureMeasure(action: 'start' | 'stop'): void { + veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: action }); +} + +/** 体温测量(单次触发) */ +export function startTemperatureMeasure(): void { + veepooFeature.veepooSendTemperatureMeasurementSwitchManager(); +} + +/** 压力测量开关(true=开启,false=关闭) */ +export function setPressureMeasure(on: boolean): void { + veepooFeature.veepooSendPressureTestManager({ switch: on }); +} + +// ── 功能模块:日常数据 ── + +/** 读取日常数据(day: 0=今天, 1=昨天, 2=前天;package: 开始包序号,默认1) */ +export function readDailyData(day: number, pkg: number = 1): void { + veepooFeature.veepooSendReadDailyDataManager({ day, package: pkg }); +} + +// ── 功能模块:精准睡眠数据 ── + +/** 精准睡眠事件类型 */ +export const SDK_EVENT_SLEEP = 4; + +/** 精准睡眠数据(SDK 回调 type=4) */ +export interface SleepData { + /** 入睡时间(时间戳字符串) */ + fallAsleepTime: string; + /** 退出睡眠时间(时间戳字符串) */ + exitSleepTime: string; + /** 起夜得分 */ + nightScore: number; + /** 深睡得分 */ + deepSleepScore: number; + /** 睡眠效率得分 */ + sleepEfficiencyScore: number; + /** 入睡效率得分 */ + fallAsleepEfficiencyScore: number; + /** 睡眠时长得分 */ + sleepTimeScore: number; + /** 睡眠质量(1-5 星) */ + sleepQuality: number; + /** 深睡时长(分钟) */ + deepSleepTime: number; + /** 浅睡时长(分钟) */ + lightSleepTime: number; + /** 其他睡眠时长(分钟) */ + otherSleepTime: number; + /** 睡眠总时长(分钟) */ + sleepTotalTime: number; + /** 首次深睡眠时长(分钟) */ + firstDeepSleepTime: number; + /** 起夜总时长(分钟) */ + nightTotalTime: number; + /** 起夜到深睡均值 */ + nightDeepSleepMeanValue: number; + /** 失眠得分 */ + insomniaScore: number; + /** 失眠次数 */ + insomniaCount: number; + /** 睡眠曲线字符串(0=深睡, 1=浅睡, 2=REM, 3=失眠, 4=苏醒) */ + sleepCurve: string; +} + +/** 读取精准睡眠数据(day: 0=今天, 1=昨天, 2=前天) */ +export function readPreciseSleepData(day: number): void { + veepooFeature.veepooSendReadPreciseSleepManager({ day }); +} + +// ── 功能模块:自动测量(B3) ── + +/** 自动测量事件类型 */ +export const SDK_EVENT_AUTO_TEST = 54; + +/** B3 自动测量功能类型枚举 */ +export const AUTO_TEST_FUN_TYPES = { + PULSE_RATE: 0, // 脉率 + BLOOD_PRESSURE: 1, // 血压 + BLOOD_GLUCOSE: 2, // 血糖 + PRESSURE: 3, // 压力 + BLOOD_OXYGEN: 4, // 血氧 + TEMPERATURE: 5, // 体温 + LORENTZ_SCATTER: 6, // 洛伦兹散点图 + HRV: 7, // HRV + BLOOD_COMPONENT: 8, // 血液成分 +} as const; + +export type AutoTestFunType = (typeof AUTO_TEST_FUN_TYPES)[keyof typeof AUTO_TEST_FUN_TYPES]; + +/** B3 自动测量配置项 */ +export interface AutoTestConfig { + /** 协议类型(不可修改) */ + protocolType: number; + /** 功能类型 0-8(可修改) */ + funTypeContent: AutoTestFunType; + /** 开关:0=关闭, 1=开启 */ + funSwitch: number; + /** 最小步进(分钟) */ + stepUnit: number; + /** 是否支持时间段修改 */ + timeSlotModify: number; + /** 是否支持时间间隔修改 */ + timeIntervalModify: number; + /** 支持的测试时间段 */ + supportTimeSlot: { startTime: string; stopTime: string }; + /** 测量间隔(分钟,按 stepUnit 递增) */ + measInterval: number; + /** 当前测试时间段 */ + currentTimeSlot: { startTime: string; stopTime: string }; +} + +/** 读取自动测量功能配置 */ +export function readAutoTestConfig(): void { + veepooFeature.veepooSendReadB3AutoTestFeatureDataManager(); +} + +/** 设置自动测量功能 */ +export function setAutoTestConfig(config: AutoTestConfig): void { + veepooFeature.veepooSendSetupB3AutoTestFeatureDataManager({ + p_protocol_type: config.protocolType, + p_fun_type_content: config.funTypeContent, + p_fun_switch: config.funSwitch, + p_step_unit: config.stepUnit, + p_time_slot_modify: config.timeSlotModify, + p_time_interval_modify: config.timeIntervalModify, + p_support_time_slot: config.supportTimeSlot, + p_meas_inv: config.measInterval, + p_cur_time_slot: config.currentTimeSlot, + }); +} + +// ── 功能模块:开关设置 ── + +/** 自动心率监测开关 */ +export function setAutoHeartRate(enabled: boolean): void { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticHRTest: enabled ? 'open' : 'close', + }); +} + +/** 自动血压监测开关 */ +export function setAutoBloodPressure(enabled: boolean): void { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticBPTest: enabled ? 'open' : 'close', + }); +} + +/** 体温自动监测 */ +export function setAutoTemperature(enabled: boolean): void { + veepooFeature.veepooSendSwitchSettingDataManager({ + VPSettingAutomaticTemperatureTest: enabled ? 'open' : 'close', + }); +} + +/** 读取体温自动监测数据 */ +export function readAutoTemperatureData(): void { + veepooFeature.veepooReadAutoTemperatureMeasurementDataManager({ day: 0 }); +} + +// ── 功能模块:设备信息 ── + +/** 读取设备电量 */ +export function readBatteryLevel(): void { + veepooFeature.veepooReadElectricQuantityManager(); +} + +// ── 日志模块 ── + +/** 设置日志级别(0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=NONE) */ +export function setLogLevel(level: number): void { + veepooLogger.setLevel(level); +} diff --git a/apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts b/apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts new file mode 100644 index 0000000..1c79ef9 --- /dev/null +++ b/apps/miniprogram/src/services/ble/veepoo/VeepooHistoryReader.ts @@ -0,0 +1,245 @@ +/** + * Veepoo 历史数据读取器 — 3天日常数据分批读取 + 上传 + * + * SDK 日常数据格式(type=5): + * - 包含计步、心率、血压、血氧、睡眠、压力、体温等 + * - Progress 字段 1-100% 表示读取进度 + * - 每次回调可能包含一包数据 + */ + +import Taro from '@tarojs/taro'; +import { readDailyData } from '../VeepooBridge'; +import type { SdkEventData } from '../VeepooBridge'; +import type { NormalizedReading } from '../types'; +import type { SleepReading } from './types'; +import { uploadReadings } from '@/services/device-sync'; + +const CHECKPOINT_KEY = 'veepoo_history_checkpoint'; +const UPLOAD_BATCH_SIZE = 20; + +interface Checkpoint { + lastProgress: number; + packagesRead: number; + deviceId: string; + timestamp: number; +} + +export type HistoryReadPhase = 'idle' | 'reading' | 'uploading' | 'done' | 'error'; + +export class VeepooHistoryReader { + private phase: HistoryReadPhase = 'idle'; + private progress = 0; + private packagesRead = 0; + private buffer: NormalizedReading[] = []; + private day = 0; + private patientId = ''; + private deviceId = ''; + private onProgress?: (progress: number, phase: HistoryReadPhase) => void; + private uploadedCount = 0; + + setCallbacks(cbs: { onProgress?: (progress: number, phase: HistoryReadPhase) => void }): void { + this.onProgress = cbs.onProgress; + } + + /** 开始读取3天数据 */ + async startRead(patientId: string, deviceId: string): Promise { + this.patientId = patientId; + this.deviceId = deviceId; + this.buffer = []; + this.uploadedCount = 0; + this.phase = 'reading'; + + // 依次读取 3 天数据 + for (let day = 0; day < 3; day++) { + this.day = day; + this.progress = 0; + this.onProgress?.(0, 'reading'); + + await this.readDay(day); + + // 刷新剩余 buffer + if (this.buffer.length > 0) { + await this.flushBuffer(); + } + } + + this.phase = 'done'; + this.onProgress?.(100, 'done'); + this.clearCheckpoint(); + + return this.uploadedCount; + } + + /** 读取单天数据 */ + private readDay(day: number): Promise { + return new Promise((resolve) => { + // 发送读取指令 + readDailyData(day, 1); + + // 进度通过 handleDailyEvent 更新 + // Progress=100 时 resolve + this.dayResolve = resolve; + + // 超时保护:30s + this.dayTimeout = setTimeout(() => { + this.dayResolve = null; + resolve(); + }, 30_000); + }); + } + + private dayResolve: (() => void) | null = null; + private dayTimeout: ReturnType | null = null; + + /** 处理 SDK 日常数据回调 */ + handleDailyEvent(data: SdkEventData): void { + if (this.phase !== 'reading') return; + + const progress = (data.Progress ?? 0) as number; + this.progress = progress; + this.onProgress?.(progress, 'reading'); + + // 解析数据 + const readings = this.parseDailyData(data); + if (readings.length > 0) { + this.buffer.push(...readings); + this.packagesRead++; + } + + // 达到批量大小就上传 + if (this.buffer.length >= UPLOAD_BATCH_SIZE) { + this.flushBuffer(); + } + + // 进度 100% 表示当天数据读取完成 + if (progress >= 100) { + if (this.dayTimeout) clearTimeout(this.dayTimeout); + this.dayTimeout = null; + const resolve = this.dayResolve; + this.dayResolve = null; + resolve?.(); + } + } + + /** 解析 SDK 日常数据为 NormalizedReading */ + private parseDailyData(data: SdkEventData): NormalizedReading[] { + const content = data.content ?? {}; + const readings: NormalizedReading[] = []; + const now = new Date(); + // 偏移到对应天 + const baseDate = new Date(now.getTime() - this.day * 86400000); + const timestamp = baseDate.toISOString(); + + // 心率 + const hr = content.heartReat ?? content.heartRate; + if (typeof hr === 'number' && hr >= 30 && hr <= 250) { + readings.push({ device_type: 'heart_rate', values: { heart_rate: hr }, measured_at: timestamp }); + } + + // 血氧 + const bo = content.bloodOxygen; + if (typeof bo === 'number' && bo >= 70 && bo <= 100) { + readings.push({ device_type: 'blood_oxygen', values: { blood_oxygen: bo }, measured_at: timestamp }); + } + + // 血压 + const bph = content.bloodPressureHigh; + const bpl = content.bloodPressureLow; + if (typeof bph === 'number' && typeof bpl === 'number' && bph > 0 && bpl > 0) { + readings.push({ device_type: 'blood_pressure', values: { systolic: bph, diastolic: bpl }, measured_at: timestamp }); + } + + // 体温 + const temp = content.bodyTemperature; + if (typeof temp === 'number' && temp > 30 && temp < 45) { + readings.push({ device_type: 'temperature', values: { temperature: temp }, measured_at: timestamp }); + } + + // 压力 + const pressure = content.pressure; + if (typeof pressure === 'number' && pressure >= 0 && pressure <= 100) { + readings.push({ device_type: 'stress', values: { value: pressure }, measured_at: timestamp }); + } + + // 步数 + const steps = content.stepCount ?? content.steps; + if (typeof steps === 'number' && steps >= 0) { + readings.push({ device_type: 'steps', values: { value: steps }, measured_at: timestamp }); + } + + return readings; + } + + /** 上传 buffer 中的数据 */ + private async flushBuffer(): Promise { + if (this.buffer.length === 0) return; + + const batch = this.buffer.splice(0, this.buffer.length); + this.phase = 'uploading'; + this.onProgress?.(this.progress, 'uploading'); + + try { + await uploadReadings(this.patientId, this.deviceId, 'Veepoo M2', batch); + this.uploadedCount += batch.length; + this.saveCheckpoint(); + } catch { + // 上传失败,放回 buffer + this.buffer.unshift(...batch); + } + + this.phase = 'reading'; + } + + private saveCheckpoint(): void { + try { + const checkpoint: Checkpoint = { + lastProgress: this.progress, + packagesRead: this.packagesRead, + deviceId: this.deviceId, + timestamp: Date.now(), + }; + Taro.setStorageSync(CHECKPOINT_KEY, JSON.stringify(checkpoint)); + } catch { /* ignore */ } + } + + private clearCheckpoint(): void { + try { Taro.removeStorageSync(CHECKPOINT_KEY); } catch { /* ignore */ } + } + + getPhase(): HistoryReadPhase { return this.phase; } + getProgress(): number { return this.progress; } + getUploadedCount(): number { return this.uploadedCount; } + + // ── 睡眠数据上传 ── + + /** 将睡眠数据转换为 NormalizedReading 并上传 */ + async uploadSleepReadings(patientId: string, deviceId: string, sleepData: SleepReading[]): Promise { + if (sleepData.length === 0) return 0; + + const now = new Date(); + const readings: NormalizedReading[] = sleepData.map((sleep) => { + // 根据天数偏移计算日期 + const baseDate = new Date(now.getTime() - sleep.day * 86400000); + return { + device_type: 'sleep', + values: { + deep_sleep_minutes: sleep.deepSleepMinutes, + light_sleep_minutes: sleep.lightSleepMinutes, + total_sleep_minutes: sleep.totalSleepMinutes, + quality_score: sleep.qualityScore, + }, + measured_at: baseDate.toISOString(), + }; + }); + + try { + await uploadReadings(patientId, deviceId, 'Veepoo M2', readings); + this.uploadedCount += readings.length; + console.log('[veepoo-history] 睡眠数据上传成功:', readings.length, '条'); + return readings.length; + } catch (err) { + console.error('[veepoo-history] 睡眠数据上传失败:', err); + return 0; + } + } +} diff --git a/apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts b/apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts new file mode 100644 index 0000000..f55e73c --- /dev/null +++ b/apps/miniprogram/src/services/ble/veepoo/VeepooPipeline.ts @@ -0,0 +1,588 @@ +/** + * Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装 + * + * 职责: + * 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪 + * 2. SDK 事件路由:registerDataListener 按 type 分发 + * 3. 测量 Promise 化:startMeasure(type) → Promise + */ + +import Taro from '@tarojs/taro'; +import { + startScan, + stopScan, + connectDevice, + registerDataListener, + registerConnectionListener, + authenticate, + disconnect as veepooDisconnect, + setHeartRateMeasure, + setBloodOxygenMeasure, + setBloodPressureMeasure, + startTemperatureMeasure, + setPressureMeasure, + readBatteryLevel, + readPreciseSleepData, + readAutoTestConfig, + setAutoHeartRate, + setAutoBloodPressure, + setAutoTemperature, + setLogLevel, + SDK_EVENT_AUTH, + SDK_EVENT_HEART_RATE, + SDK_EVENT_BLOOD_OXYGEN, + SDK_EVENT_BLOOD_PRESSURE, + SDK_EVENT_TEMPERATURE, + SDK_EVENT_PRESSURE, + SDK_EVENT_DAILY, + SDK_EVENT_SLEEP, + SDK_EVENT_AUTO_TEST, + DEVICE_STATE, +} from '../VeepooBridge'; +import type { SdkEventData } from '../VeepooBridge'; +import type { MeasureType, MeasureResult, SleepReading } from './types'; + +const AUTH_TIMEOUT = 8_000; +const AUTH_POLL_INTERVAL = 500; +const MEASURE_SETTLE_DELAY = 1_500; + +/** pending 测量的 resolve/reject 句柄 */ +interface PendingMeasure { + type: MeasureType; + resolve: (result: MeasureResult) => void; + reject: (error: Error) => void; + timer: ReturnType; + lastValue: number | null; + lastValues: Record; + settleTimer: ReturnType | null; +} + +/** SDK type 到 MeasureType 的映射 */ +const SDK_TYPE_TO_MEASURE: Record = { + [SDK_EVENT_HEART_RATE]: 'heart_rate', + [SDK_EVENT_BLOOD_OXYGEN]: 'blood_oxygen', + [SDK_EVENT_BLOOD_PRESSURE]: 'blood_pressure', + [SDK_EVENT_TEMPERATURE]: 'temperature', + [SDK_EVENT_PRESSURE]: 'pressure', +}; + +export type ConnectionChangeCallback = (connected: boolean, deviceId: string) => void; +export type AuthResultCallback = (success: boolean) => void; +export type MeasureEventCallback = (type: MeasureType, data: Record) => void; +export type DailyDataCallback = (data: SdkEventData) => void; +export type SleepDataCallback = (day: number, sleep: SleepReading) => void; + +export class VeepooPipeline { + private pending: PendingMeasure | null = null; + private isConnected = false; + private deviceId = ''; + + /** 睡眠数据读取 Promise resolve 队列 */ + private sleepResolvers: Map void> = new Map(); + private sleepTimeouts: Map> = new Map(); + + private onConnectionChange?: ConnectionChangeCallback; + private onAuthResult?: AuthResultCallback; + private onMeasureEvent?: MeasureEventCallback; + private onDailyData?: DailyDataCallback; + private onSleepData?: SleepDataCallback; + + /** 注册回调 */ + setCallbacks(cbs: { + onConnectionChange?: ConnectionChangeCallback; + onAuthResult?: AuthResultCallback; + onMeasureEvent?: MeasureEventCallback; + onDailyData?: DailyDataCallback; + onSleepData?: SleepDataCallback; + }): void { + this.onConnectionChange = cbs.onConnectionChange; + this.onAuthResult = cbs.onAuthResult; + this.onMeasureEvent = cbs.onMeasureEvent; + this.onDailyData = cbs.onDailyData; + this.onSleepData = cbs.onSleepData; + } + + /** 全流程:扫描 → 连接 → 注册监听 → 认证 */ + async connect(targetName: string, debug = false): Promise { + console.log('[veepoo-pipeline] connect() 开始, target:', targetName); + if (debug) setLogLevel(0); + + // 1. 扫描 + console.log('[veepoo-pipeline] Step 1: 扫描...'); + const device = await this.scanFor(targetName); + if (!device) { + console.error('[veepoo-pipeline] 扫描未找到设备'); + throw new Error(`未找到设备 ${targetName}`); + } + console.log('[veepoo-pipeline] 找到设备:', (device as Record)?.deviceId); + + // 2. 连接 + console.log('[veepoo-pipeline] Step 2: 连接...'); + const connRes = await connectDevice(device); + console.log('[veepoo-pipeline] 连接结果:', JSON.stringify(connRes)); + // SDK 连接成功返回 errno=0 或 connection=true,两种都要兼容 + const ok = connRes?.connection === true || connRes?.errno === 0 || connRes?.errCode === 0; + if (!ok) throw new Error('连接失败'); + + const id = (device as Record).deviceId as string; + this.deviceId = id; + this.isConnected = true; + + // 3. 注册数据监听(连接成功后) + registerDataListener((data) => this.routeEvent(data)); + registerConnectionListener((res) => { + this.isConnected = res.connected; + this.onConnectionChange?.(res.connected, res.deviceId); + }); + + // 4. 认证(延迟 500ms) + await delay(500); + authenticate(); + + // 5. 等待认证结果 + const authOk = await this.waitForAuth(); + if (!authOk) throw new Error('设备认证失败,请重新连接'); + + // 6. 读取电量 + readBatteryLevel(); + + return id; + } + + /** 扫描指定名称的设备 */ + private scanFor(targetName: string): Promise { + return new Promise((resolve) => { + let found: unknown = null; + const upper = targetName.toUpperCase(); + + startScan((device) => { + const d = device as Record; + const name = String(d.localName ?? d.name ?? '').toUpperCase(); + if (name.includes(upper) && !found) { + found = device; + stopScan().then(() => resolve(found)); + } + }); + + setTimeout(() => { + if (!found) { + stopScan().then(() => resolve(null)); + } + }, 10_000); + }); + } + + /** 等待认证结果(轮询 deviceChipStatus) */ + private waitForAuth(): Promise { + return new Promise((resolve) => { + const start = Date.now(); + + const poll = () => { + try { + const status = Taro.getStorageSync('deviceChipStatus'); + if (status === 'successfulVerification' || status === 'passTheVerification') { + this.onAuthResult?.(true); + resolve(true); + return; + } + } catch { /* ignore */ } + + if (Date.now() - start >= AUTH_TIMEOUT) { + this.onAuthResult?.(false); + resolve(false); + return; + } + + setTimeout(poll, AUTH_POLL_INTERVAL); + }; + + poll(); + }); + } + + /** SDK 事件路由 */ + private routeEvent(data: SdkEventData): void { + const eventType = data.type; + + // 认证回调 + if (eventType === SDK_EVENT_AUTH) { + const content = data.content ?? {}; + const password = content.VPDevicepassword; + if (password === 'passTheVerification' || password === 'successfulVerification') { + this.onAuthResult?.(true); + } + return; + } + + // 日常数据 + if (eventType === SDK_EVENT_DAILY) { + this.onDailyData?.(data); + return; + } + + // 精准睡眠数据 + if (eventType === SDK_EVENT_SLEEP) { + this.handleSleepEvent(data); + return; + } + + // 自动测量功能回调 + if (eventType === SDK_EVENT_AUTO_TEST) { + console.log('[veepoo-pipeline] 自动测量配置回调:', JSON.stringify(data).substring(0, 300)); + return; + } + + // 测量数据 + const measureType = SDK_TYPE_TO_MEASURE[eventType]; + if (!measureType) return; + + this.handleMeasureEvent(measureType, data); + this.onMeasureEvent?.(measureType, data.content ?? {}); + } + + /** 处理测量事件 */ + private handleMeasureEvent(type: MeasureType, data: SdkEventData): void { + if (!this.pending || this.pending.type !== type) return; + + const content = data.content ?? {}; + + // 检查设备状态错误 + const deviceBusy = content.deviceBusy === true; + const notWear = content.notWear === true; + const state = data.state; + const ack = data.ack; + + if (deviceBusy) { + this.rejectPending(new Error('设备正忙,请稍后重试')); + return; + } + if (notWear || state === DEVICE_STATE.NOT_WORN) { + this.rejectPending(new Error('请将手环佩戴到手腕上')); + return; + } + if (state === DEVICE_STATE.CHARGING) { + this.rejectPending(new Error('设备正在充电,请取出后重试')); + return; + } + if (state === DEVICE_STATE.LOW_BATTERY) { + this.rejectPending(new Error('设备电量不足,请充电后重试')); + return; + } + if (type === 'pressure' && ack === 2) { + this.rejectPending(new Error('设备电量不足')); + return; + } + if (type === 'pressure' && ack === 3) { + this.rejectPending(new Error('设备正在测量其他数据')); + return; + } + if (type === 'pressure' && ack === 4) { + this.rejectPending(new Error('佩戴检测未通过')); + return; + } + + // 提取数值 + const values = this.extractValues(type, content); + if (!values) return; + + // 更新 pending 最新值 + this.pending.lastValues = values; + + // 对于进度型指标,检查是否完成 + const progress = data.Progress; + if (progress !== undefined && progress >= 100) { + this.resolvePending(values); + return; + } + + // 对于持续测量型/单次型,收到第一个有效值后延迟 settle + if (this.pending.settleTimer === null) { + this.pending.settleTimer = setTimeout(() => { + if (this.pending && this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) { + this.resolvePending(this.pending.lastValues); + } + }, MEASURE_SETTLE_DELAY); + } + } + + /** 从 SDK content 提取标准化数值 */ + private extractValues(type: MeasureType, content: Record): Record | null { + switch (type) { + case 'heart_rate': { + const hr = Number(content.heartRate); + if (hr >= 30 && hr <= 250) return { heart_rate: hr }; + return null; + } + case 'blood_oxygen': { + const bo = Number(content.bloodOxygen); + if (bo >= 70 && bo <= 100) return { blood_oxygen: bo }; + return null; + } + case 'blood_pressure': { + const high = Number(content.bloodPressureHigh); + const low = Number(content.bloodPressureLow); + if (high > 0 && low > 0) return { systolic: high, diastolic: low }; + return null; + } + case 'temperature': { + const temp = Number(content.bodyTemperature); + if (temp > 30 && temp < 45) return { temperature: temp }; + return null; + } + case 'pressure': { + const p = Number(content.pressure); + if (p >= 0 && p <= 100) return { pressure: p }; + return null; + } + default: + return null; + } + } + + /** 发起测量 */ + startMeasure(type: MeasureType): Promise { + if (this.pending) { + throw new Error(`正在测量 ${this.pending.type},请等待完成`); + } + if (!this.isConnected) { + throw new Error('设备未连接'); + } + + return new Promise((resolve, reject) => { + const timeout = getMeasureTimeout(type); + + const timer = setTimeout(() => { + this.rejectPending(new Error('测量超时,请重试')); + }, timeout); + + this.pending = { + type, + resolve, + reject, + timer, + lastValue: null, + lastValues: {}, + settleTimer: null, + }; + + // 发送 SDK 测量指令 + this.sendMeasureCommand(type); + }); + } + + /** 取消当前测量 */ + cancelMeasure(): void { + if (!this.pending) return; + this.stopMeasureCommand(this.pending.type); + if (this.pending.lastValues && Object.keys(this.pending.lastValues).length > 0) { + this.resolvePending(this.pending.lastValues); + } else { + this.rejectPending(new Error('测量已取消')); + } + } + + /** 发送 SDK 测量指令 */ + private sendMeasureCommand(type: MeasureType): void { + switch (type) { + case 'heart_rate': + setHeartRateMeasure(true); + break; + case 'blood_oxygen': + setBloodOxygenMeasure('start'); + break; + case 'blood_pressure': + setBloodPressureMeasure('start'); + break; + case 'temperature': + startTemperatureMeasure(); + break; + case 'pressure': + setPressureMeasure(true); + break; + } + } + + /** 发送 SDK 停止测量指令 */ + private stopMeasureCommand(type: MeasureType): void { + switch (type) { + case 'heart_rate': + setHeartRateMeasure(false); + break; + case 'blood_oxygen': + setBloodOxygenMeasure('stop'); + break; + case 'blood_pressure': + setBloodPressureMeasure('stop'); + break; + case 'temperature': + break; // 体温是单次触发,无法停止 + case 'pressure': + setPressureMeasure(false); + break; + } + } + + /** 成功 resolve pending 测量 */ + private resolvePending(values: Record): void { + if (!this.pending) return; + const p = this.pending; + this.pending = null; + + clearTimeout(p.timer); + if (p.settleTimer) clearTimeout(p.settleTimer); + + // 停止持续测量型指标的 SDK 指令 + this.stopMeasureCommand(p.type); + + p.resolve({ + type: p.type, + values, + measuredAt: Date.now(), + }); + } + + /** 失败 reject pending 测量 */ + private rejectPending(error: Error): void { + if (!this.pending) return; + const p = this.pending; + this.pending = null; + + clearTimeout(p.timer); + if (p.settleTimer) clearTimeout(p.settleTimer); + + // 停止 SDK 指令 + this.stopMeasureCommand(p.type); + + p.reject(error); + } + + // ── 睡眠数据 ── + + /** 读取单天精准睡眠数据,返回 Promise */ + readSleepData(day: number): Promise { + if (!this.isConnected) { + return Promise.reject(new Error('设备未连接')); + } + + return new Promise((resolve) => { + this.sleepResolvers.set(day, resolve); + + // 超时保护 30s + const timer = setTimeout(() => { + this.sleepResolvers.delete(day); + this.sleepTimeouts.delete(day); + resolve(null); + }, 30_000); + this.sleepTimeouts.set(day, timer); + + // 发送 SDK 读取指令 + readPreciseSleepData(day); + }); + } + + /** 读取 3 天睡眠数据 */ + async readAllSleepData(): Promise { + const results: SleepReading[] = []; + for (let day = 0; day < 3; day++) { + const sleep = await this.readSleepData(day); + if (sleep) { + results.push(sleep); + } + } + return results; + } + + /** 处理 SDK 睡眠数据回调(type=4) */ + private handleSleepEvent(data: SdkEventData): void { + const progress = data.Progress ?? 0; + const readDay = (data as { readDay?: number }).readDay ?? 0; + + // 进度未达 100% 忽略 + if (progress < 100) return; + + const content = data.content ?? {}; + const sleep = this.parseSleepData(readDay, content as Record); + + // 通知回调 + if (sleep) { + this.onSleepData?.(readDay, sleep); + } + + // resolve 等待中的 Promise + const resolve = this.sleepResolvers.get(readDay); + if (resolve) { + const timer = this.sleepTimeouts.get(readDay); + if (timer) clearTimeout(timer); + this.sleepResolvers.delete(readDay); + this.sleepTimeouts.delete(readDay); + resolve(sleep); + } + } + + /** 从 SDK content 解析精准睡眠数据 */ + private parseSleepData(day: number, content: Record): SleepReading | null { + const total = Number(content.sleepTotalTime ?? 0); + if (total <= 0) return null; + + return { + day, + deepSleepMinutes: Number(content.deepSleepTime ?? 0), + lightSleepMinutes: Number(content.lightSleepTime ?? 0), + otherSleepMinutes: Number(content.otherSleepTime ?? 0), + totalSleepMinutes: total, + qualityScore: Number(content.sleepQuality ?? 0), + fallAsleepTime: String(content.fallAsleepTime ?? ''), + exitSleepTime: String(content.exitSleepTime ?? ''), + }; + } + + // ── 自动测量 ── + + /** 开启自动测量(心率 + 血压 + 血氧 + 体温) */ + enableAutoMeasurement(): void { + if (!this.isConnected) return; + + console.log('[veepoo-pipeline] 开启自动测量功能'); + setAutoHeartRate(true); + setAutoBloodPressure(true); + setAutoTemperature(true); + + // 读取当前自动测量配置 + readAutoTestConfig(); + } + + /** 断开连接 */ + async disconnect(): Promise { + if (this.pending) { + this.rejectPending(new Error('设备已断开')); + } + this.isConnected = false; + this.deviceId = ''; + await veepooDisconnect(); + } + + /** 获取连接状态 */ + getConnected(): boolean { + return this.isConnected; + } + + /** 获取设备 ID */ + getDeviceId(): string { + return this.deviceId; + } +} + +function delay(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function getMeasureTimeout(type: MeasureType): number { + const timeouts: Record = { + heart_rate: 60_000, + blood_oxygen: 60_000, + blood_pressure: 120_000, + temperature: 60_000, + pressure: 90_000, + }; + return timeouts[type]; +} diff --git a/apps/miniprogram/src/services/ble/veepoo/index.ts b/apps/miniprogram/src/services/ble/veepoo/index.ts new file mode 100644 index 0000000..74fa0f7 --- /dev/null +++ b/apps/miniprogram/src/services/ble/veepoo/index.ts @@ -0,0 +1,21 @@ +export { VeepooPipeline } from './VeepooPipeline'; +export { VeepooHistoryReader } from './VeepooHistoryReader'; +export type { + ConnectionChangeCallback, + AuthResultCallback, + MeasureEventCallback, + DailyDataCallback, +} from './VeepooPipeline'; +export type { + MeasureType, + MeasurePhase, + MeasureStatus, + MeasureResult, + MeasureConfig, + ConnectionPhase, + VeepooDeviceInfo, + HistorySyncState, + SleepReading, + AutoTestSyncState, +} from './types'; +export { MEASURE_TYPES, MEASURE_CONFIG } from './types'; diff --git a/apps/miniprogram/src/services/ble/veepoo/types.ts b/apps/miniprogram/src/services/ble/veepoo/types.ts new file mode 100644 index 0000000..5c42ec8 --- /dev/null +++ b/apps/miniprogram/src/services/ble/veepoo/types.ts @@ -0,0 +1,152 @@ +/** Veepoo 管线专用类型定义 */ + +/** 测量指标类型 */ +export type MeasureType = + | 'heart_rate' + | 'blood_oxygen' + | 'blood_pressure' + | 'temperature' + | 'pressure'; + +/** 所有支持的测量指标 */ +export const MEASURE_TYPES: readonly MeasureType[] = [ + 'heart_rate', + 'blood_oxygen', + 'blood_pressure', + 'temperature', + 'pressure', +] as const; + +/** 测量指标配置 */ +export interface MeasureConfig { + label: string; + unit: string; + icon: string; + color: string; + /** 正常范围 [min, max] */ + normalRange: [number, number]; + /** 测量超时(毫秒) */ + timeout: number; + /** 测量模式 */ + mode: 'continuous' | 'progress' | 'single'; +} + +/** 各指标配置表 */ +export const MEASURE_CONFIG: Record = { + heart_rate: { + label: '心率', + unit: 'bpm', + icon: '♥', + color: '#EF4444', + normalRange: [60, 100], + timeout: 60_000, + mode: 'continuous', + }, + blood_oxygen: { + label: '血氧', + unit: '%', + icon: 'O₂', + color: '#3B82F6', + normalRange: [95, 100], + timeout: 60_000, + mode: 'continuous', + }, + blood_pressure: { + label: '血压', + unit: 'mmHg', + icon: '↕', + color: '#8B5CF6', + normalRange: [90, 140], + timeout: 120_000, + mode: 'progress', + }, + temperature: { + label: '体温', + unit: '°C', + icon: 'T', + color: '#F59E0B', + normalRange: [36.0, 37.3], + timeout: 60_000, + mode: 'single', + }, + pressure: { + label: '压力', + unit: '', + icon: '~', + color: '#6366F1', + normalRange: [1, 40], + timeout: 90_000, + mode: 'progress', + }, +}; + +/** 连接阶段 */ +export type ConnectionPhase = + | 'idle' + | 'scanning' + | 'connecting' + | 'authenticating' + | 'ready' + | 'disconnected' + | 'error'; + +/** 测量阶段 */ +export type MeasurePhase = 'idle' | 'measuring' | 'success' | 'error'; + +/** 单个指标的测量状态 */ +export interface MeasureStatus { + phase: MeasurePhase; + progress: number; + currentValue: number | null; + result: MeasureResult | null; + error: string | null; +} + +/** 测量结果 */ +export interface MeasureResult { + type: MeasureType; + values: Record; + measuredAt: number; +} + +/** 设备信息 */ +export interface VeepooDeviceInfo { + deviceId: string; + name: string; + batteryLevel: number | null; +} + +/** 历史数据同步状态 */ +export interface HistorySyncState { + phase: 'idle' | 'reading' | 'uploading' | 'done'; + progress: number; + packagesRead: number; + lastCheckpoint: number; +} + +/** 睡眠数据(从 SDK 精准睡眠解析) */ +export interface SleepReading { + /** 读取天数(0=今天, 1=昨天, 2=前天) */ + day: number; + /** 深睡时长(分钟) */ + deepSleepMinutes: number; + /** 浅睡时长(分钟) */ + lightSleepMinutes: number; + /** 其他睡眠时长(分钟) */ + otherSleepMinutes: number; + /** 睡眠总时长(分钟) */ + totalSleepMinutes: number; + /** 睡眠质量评分(1-5 星) */ + qualityScore: number; + /** 入睡时间(时间戳字符串) */ + fallAsleepTime: string; + /** 退出睡眠时间(时间戳字符串) */ + exitSleepTime: string; +} + +/** 自动测量同步状态 */ +export interface AutoTestSyncState { + phase: 'idle' | 'reading_config' | 'configuring' | 'configured'; + enabledTypes: string[]; + intervalMinutes: number; +} diff --git a/apps/miniprogram/src/stores/veepoo.ts b/apps/miniprogram/src/stores/veepoo.ts new file mode 100644 index 0000000..60912d6 --- /dev/null +++ b/apps/miniprogram/src/stores/veepoo.ts @@ -0,0 +1,335 @@ +import { create } from 'zustand'; +import { VeepooPipeline } from '@/services/ble/veepoo/VeepooPipeline'; +import { VeepooHistoryReader } from '@/services/ble/veepoo/VeepooHistoryReader'; +import type { + MeasureType, + MeasureStatus, + MeasureResult, + ConnectionPhase, + VeepooDeviceInfo, + HistorySyncState, + SleepReading, +} from '@/services/ble/veepoo/types'; +import { MEASURE_TYPES } from '@/services/ble/veepoo/types'; +import { useAuthStore } from './auth'; + +/** 初始化每个指标的默认状态 */ +function initialMeasureStates(): Record { + const states = {} as Record; + for (const t of MEASURE_TYPES) { + states[t] = { phase: 'idle', progress: 0, currentValue: null, result: null, error: null }; + } + return states; +} + +interface VeepooState { + // 连接 + connectionPhase: ConnectionPhase; + device: VeepooDeviceInfo | null; + error: string | null; + + // 测量 + activeMeasure: MeasureType | null; + measureStates: Record; + + // 历史 + historySync: HistorySyncState; + + // 睡眠 + sleepData: SleepReading[]; + sleepLoading: boolean; + + // Actions + connect: (targetName?: string) => Promise; + disconnect: () => Promise; + startMeasure: (type: MeasureType) => Promise; + cancelMeasure: () => void; + syncHistory: (patientId: string) => Promise; + readSleepData: () => Promise; + enableAutoMeasurement: () => void; + reset: () => void; +} + +let pipelineInstance: VeepooPipeline | null = null; +let historyReaderInstance: VeepooHistoryReader | null = null; + +function getPipeline(): VeepooPipeline { + if (!pipelineInstance) { + pipelineInstance = new VeepooPipeline(); + } + return pipelineInstance; +} + +function getHistoryReader(): VeepooHistoryReader { + if (!historyReaderInstance) { + historyReaderInstance = new VeepooHistoryReader(); + } + return historyReaderInstance; +} + +export const useVeepooStore = create((set, get) => ({ + connectionPhase: 'idle', + device: null, + error: null, + activeMeasure: null, + measureStates: initialMeasureStates(), + historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 }, + sleepData: [], + sleepLoading: false, + + connect: async (targetName = 'M2') => { + console.log('[veepoo-store] connect() 开始, target:', targetName); + set({ connectionPhase: 'scanning', error: null }); + const pipeline = getPipeline(); + const historyReader = getHistoryReader(); + + // 注册全部回调(包含新增的 onSleepData) + pipeline.setCallbacks({ + onConnectionChange: (connected) => { + if (!connected) { + set({ connectionPhase: 'disconnected', device: null }); + } + }, + onAuthResult: (success) => { + if (success) { + set({ connectionPhase: 'ready' }); + } + }, + onMeasureEvent: (type, data) => { + const state = get(); + if (state.activeMeasure !== type) return; + + const value = extractDisplayValue(type, data); + set({ + measureStates: { + ...state.measureStates, + [type]: { + ...state.measureStates[type], + phase: 'measuring', + progress: (data.Progress ?? data.progress ?? 0) as number, + currentValue: value, + }, + }, + }); + }, + onDailyData: (data) => { + // 转发给 HistoryReader 处理 + historyReader.handleDailyEvent(data); + + const progress = data.Progress ?? 0; + set((s) => ({ + historySync: { ...s.historySync, progress: progress as number }, + })); + }, + onSleepData: (_day, sleep) => { + // 收集睡眠数据到 store + set((s) => ({ + sleepData: [...s.sleepData, sleep], + })); + }, + }); + + // 注册 HistoryReader 进度回调 + historyReader.setCallbacks({ + onProgress: (progress, phase) => { + set((s) => ({ + historySync: { + ...s.historySync, + phase: phase === 'uploading' ? 'uploading' : 'reading', + progress, + }, + })); + }, + }); + + try { + set({ connectionPhase: 'connecting' }); + const deviceId = await pipeline.connect(targetName); + set({ + connectionPhase: 'authenticating', + device: { deviceId, name: targetName, batteryLevel: null }, + }); + + // 认证结果由 onAuthResult 回调设置 + // 等待 ready 状态(最多 10s) + await waitForState(() => get().connectionPhase === 'ready', 10_000); + + // 认证通过后:自动同步历史 + 读取睡眠 + 开启自动测量 + const patient = useAuthStore.getState().currentPatient; + const readyState = get().connectionPhase === 'ready'; + if (patient && readyState) { + const deviceIdForReader = get().device?.deviceId ?? 'veepoo_m2'; + + // 并行执行三件事: + // 1. 同步日常历史数据(后台执行,进度通过回调更新) + get().syncHistory(patient.id); + + // 2. 读取睡眠数据 → 完成后自动上传 + get().readSleepData().then((sleepResults) => { + if (sleepResults.length > 0) { + historyReader.uploadSleepReadings(patient.id, deviceIdForReader, sleepResults); + } + }); + + // 3. 开启自动测量(心率+血压+体温) + pipeline.enableAutoMeasurement(); + } + } catch (err) { + console.error('[veepoo-store] connect 失败:', err); + set({ + connectionPhase: 'error', + error: err instanceof Error ? err.message : '连接失败', + }); + } + }, + + disconnect: async () => { + const pipeline = getPipeline(); + await pipeline.disconnect(); + set({ + connectionPhase: 'idle', + device: null, + error: null, + activeMeasure: null, + measureStates: initialMeasureStates(), + sleepData: [], + sleepLoading: false, + }); + }, + + startMeasure: async (type: MeasureType) => { + const state = get(); + if (state.activeMeasure) { + throw new Error(`正在测量 ${state.activeMeasure},请等待完成`); + } + if (state.connectionPhase !== 'ready') { + throw new Error('设备未就绪'); + } + + set({ + activeMeasure: type, + measureStates: { + ...state.measureStates, + [type]: { phase: 'measuring', progress: 0, currentValue: null, result: null, error: null }, + }, + }); + + const pipeline = getPipeline(); + try { + const result = await pipeline.startMeasure(type); + set((s) => ({ + activeMeasure: null, + measureStates: { + ...s.measureStates, + [type]: { phase: 'success', progress: 100, currentValue: null, result, error: null }, + }, + })); + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : '测量失败'; + set((s) => ({ + activeMeasure: null, + measureStates: { + ...s.measureStates, + [type]: { phase: 'error', progress: 0, currentValue: null, result: null, error: msg }, + }, + })); + throw err; + } + }, + + cancelMeasure: () => { + const pipeline = getPipeline(); + pipeline.cancelMeasure(); + }, + + syncHistory: async (patientId: string) => { + const deviceId = get().device?.deviceId ?? 'veepoo_m2'; + set((s) => ({ historySync: { ...s.historySync, phase: 'reading', progress: 0 } })); + + try { + const historyReader = getHistoryReader(); + const count = await historyReader.startRead(patientId, deviceId); + set((s) => ({ + historySync: { ...s.historySync, phase: 'done', progress: 100, packagesRead: count }, + })); + console.log('[veepoo-store] 历史数据同步完成, 上传:', count, '条'); + } catch (err) { + console.error('[veepoo-store] 历史数据同步失败:', err); + set((s) => ({ historySync: { ...s.historySync, phase: 'done', progress: 100 } })); + } + }, + + readSleepData: async () => { + const pipeline = getPipeline(); + if (!pipeline.getConnected()) { + console.warn('[veepoo-store] 设备未连接,跳过睡眠数据读取'); + return []; + } + + set({ sleepLoading: true, sleepData: [] }); + try { + const sleepResults = await pipeline.readAllSleepData(); + set({ sleepData: sleepResults, sleepLoading: false }); + console.log('[veepoo-store] 睡眠数据读取完成:', sleepResults.length, '天'); + return sleepResults; + } catch (err) { + console.error('[veepoo-store] 睡眠数据读取失败:', err); + set({ sleepLoading: false }); + return []; + } + }, + + enableAutoMeasurement: () => { + const pipeline = getPipeline(); + pipeline.enableAutoMeasurement(); + }, + + reset: () => { + set({ + connectionPhase: 'idle', + device: null, + error: null, + activeMeasure: null, + measureStates: initialMeasureStates(), + historySync: { phase: 'idle', progress: 0, packagesRead: 0, lastCheckpoint: 0 }, + sleepData: [], + sleepLoading: false, + }); + }, +})); + +/** 从 SDK 事件 content 提取显示值 */ +function extractDisplayValue(type: MeasureType, content: Record): number | null { + switch (type) { + case 'heart_rate': { + const v = Number(content.heartRate); + return v >= 30 && v <= 250 ? v : null; + } + case 'blood_oxygen': { + const v = Number(content.bloodOxygen); + return v >= 70 && v <= 100 ? v : null; + } + case 'blood_pressure': + return Number(content.bloodPressureHigh) || null; + case 'temperature': + return Number(content.bodyTemperature) || null; + case 'pressure': + return Number(content.pressure) || null; + default: + return null; + } +} + +/** 轮询等待状态满足条件 */ +function waitForState(check: () => boolean, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + const start = Date.now(); + const poll = () => { + if (check()) { resolve(); return; } + if (Date.now() - start >= timeoutMs) { reject(new Error('等待超时')); return; } + setTimeout(poll, 200); + }; + poll(); + }); +} diff --git a/docs/design/veepoo-measure-prototype.html b/docs/design/veepoo-measure-prototype.html new file mode 100644 index 0000000..4f44458 --- /dev/null +++ b/docs/design/veepoo-measure-prototype.html @@ -0,0 +1,879 @@ + + + + + +Veepoo M2 手环 — 测量页 & 上传页原型 + + + + + +
+
页面 1 · 测量页(就绪态 — 心率测量中)
+
+
+ +
+ 9:41 +
+ ●●●● + WiFi + 85% +
+
+ +
+ +
+
+
+ M2 手环 + 85% +
+ +
+ + +
+ +
+
+ 心率 +
+ +
+
O₂
+ 血氧 +
+ +
+
+ 血压 +
+ +
+
T
+ 体温 +
+ +
+
~
+ 压力 +
+
+ + +
+
+ + + + +
+ 72 + bpm +
+
+ + +
+
+
+ + +
♥ 心率正常
+
+ + +
+

测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。

+
+ + +
+ + +
+
+
+
+
+ + + +
+
页面 2 · 数据上传页(结果汇总 + 上传)
+
+
+ +
+ 9:41 +
+ ●●●● + WiFi + 85% +
+
+ +
+ + + + +
+ +
+
+
心率
+
+ 72 + bpm +
+
● 正常
+
+ + +
+
+
血氧
+
+ 98 + % +
+
● 正常
+
+ + +
+
+
血压
+
+ 135 + / 88 mmHg +
+
● 偏高
+
+ + +
+
+
体温
+
未测量
+
+ + +
+
+
压力
+
未测量
+
+
+ + + +
+
+
+
+ + + +
+
页面 1 · 测量页(未连接态)
+
+
+
+ 9:41 +
+ ●●●● + WiFi + 85% +
+
+ +
+ +
+
+
+ BT +
+
+ +

+ M2 手环健康测量 +

+

+ 请确保手环已佩戴且蓝牙已开启 +

+ + +
+
+
+
+ + + +
+
页面 1 · 测量页(血压测量完成)
+
+
+
+ 9:42 +
+ ●●●● + WiFi + 85% +
+
+ +
+
+
+
+ M2 手环 + 85% +
+ +
+ + +
+
+
+ 心率 +
+
+
O₂
+ 血氧 +
+
+
+ 血压 +
+
+
T
+ 体温 +
+
+
~
+ 压力 +
+
+ + +
+
+ + + + +
+ 135/88 + mmHg +
+
+ + +
↕ 血压偏高,建议关注
+
+ +
+

测量数据仅供参考,不作为医学诊断依据。如有不适请及时就医。

+
+ +
+ + +
+
+
+
+
+ + + + + diff --git a/docs/design/veepoo-prototype-preview.png b/docs/design/veepoo-prototype-preview.png new file mode 100644 index 0000000..be4cd1c Binary files /dev/null and b/docs/design/veepoo-prototype-preview.png differ diff --git a/wiki/index.md b/wiki/index.md index 391a1f7..0450f65 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -4,7 +4,7 @@ ## 关键数字 -> 最后更新: 2026-05-30 | 数据截止: feat/media-library-banner 分支(Veepoo M2 BLE 实时测量 + SDK 对接全流程修复) +> 最后更新: 2026-05-31 | 数据截止: feat/media-library-banner 分支(Veepoo M2 BLE 管线扩展:精准睡眠 + 自动测量 + UI 重构) | 指标 | 值 | |------|-----| @@ -18,7 +18,7 @@ | erp-ai 实体 | 20 个 Entity(95 文件,4 AI Provider,chat_handler 支持 FC/Ollama fallback) | | 全系统 Entity | **115 个**(58 health + 20 ai + 33 基础 + 4 core) | | Web 前端 | 316 个 TS/TSX 文件(54 活跃路由,83 API 模块,108 页面) | -| 微信小程序 | Taro 4.2 + React 18,185 个 TS/TSX 文件 / 62 页面(15 主包 + 47 分包) / 4 TabBar + 医生端独立分包,34 组件(ui 21 + patterns 4 + 独立 9) / 49 service 文件 / 5 Zustand store / 12 hooks,统一组件库 + CSS 变量主题(103 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**:Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重;**Veepoo M2 BLE 管线**:独立管线(VeepooBridge 14 API + VeepooPipeline 事件路由 + VeepooStore 状态管理)+ **原生分包页面**(`pkg-veepoo` 原生 JS+WXML,脱离 Taro 直接调用 SDK,绕过框架兼容性限制)+ 实时测量页面(心率/血氧/血压/体温/压力 5 指标,圆环仪表盘 + 长者模式适配)+ 3 天历史数据同步(VeepooHistoryReader 分批上传 + 断点续传);**preloadRule 已移除 pkg-health** 防止 380KB SDK 预加载导致首页 DevTools 卡死;**构建优化**:`lazyCodeLoading: requiredComponents` 仅生产构建启用(dev 下已知 DevTools 卡死 bug),`addChunkPages` 仅 TabBar 页注入 common chunk,主包 dev 892KB / prod 766KB;**五维度分析评分 6.7/10**(架构7.25/安全6.0/UX7.4/工程6.2) | +| 微信小程序 | Taro 4.2 + React 18,185 个 TS/TSX 文件 / 62 页面(15 主包 + 47 分包) / 4 TabBar + 医生端独立分包,34 组件(ui 21 + patterns 4 + 独立 9) / 49 service 文件 / 5 Zustand store / 12 hooks,统一组件库 + CSS 变量主题(103 SCSS 全量接入 `var(--tk-*)`,字号 token 对齐原型统计,医生端 `.doctor-mode` 靛蓝覆盖,登录页账号密码+微信一键登录);**Phase 2+3 完成**:Token 构建时生成 + Canvas 适老 + PII 清理 + 缓存加密 + any 清零 + 大文件拆分(3→6) + 触觉反馈 + 导航状态保持 + 独立分包 + CI 集成 + HMAC 请求签名;**并发安全**:长轮询独立通道 `requestUnlimited` + ConcurrencyLimiter(12) + safeNavigateTo 全局页栈保护 + reLaunch 去重;**Veepoo M2 BLE 管线**:独立管线(VeepooBridge 24 API(含精准睡眠/自动测量/开关设置/体温自动数据)+ VeepooPipeline 事件路由(type=1/4/5/6/18/31/51/54/58)+ VeepooHistoryReader 日常+睡眠上传 + VeepooStore 状态管理(含 sleepData/sleepLoading))+ **原生分包页面**(`pkg-veepoo` 原生 JS+WXML,脱离 Taro 直接调用 SDK,绕过框架兼容性限制)+ 实时测量页面(心率/血氧/血压/体温/压力 5 指标,圆环仪表盘 + 长者模式适配)+ 3 天历史数据同步(VeepooHistoryReader 分批上传 + 断点续传)+ **精准睡眠数据自动读取**(认证后自动读取 3 天睡眠:深睡/浅睡/总时长/质量评分,通过 Storage 回传 Taro 页面)+ **自动测量功能**(认证后自动开启心率/血压/体温自动监测);**UI 重构**:测量页药丸式选择器 + SVG 圆环仪表盘 + 健康评估标签;数据上传页 2 列结果卡片网格 + 彩色条标识 + 睡眠数据卡片(★ 评分 + 总时长);**preloadRule 已移除 pkg-health** 防止 380KB SDK 预加载导致首页 DevTools 卡死;**构建优化**:`lazyCodeLoading: requiredComponents` 仅生产构建启用(dev 下已知 DevTools 卡死 bug),`addChunkPages` 仅 TabBar 页注入 common chunk,主包 dev 892KB / prod 766KB;**五维度分析评分 6.7/10**(架构7.25/安全6.0/UX7.4/工程6.2) | | 前端测试 | Web 62 单元测试文件(~693 断言) + 17 E2E spec(13 Web + 4 MP,~64 断言);小程序 12 单元测试文件(127 断言) + 4 E2E spec(~16 断言),覆盖率 ~6% | | 后端测试 | **1030 个函数**(839 同步 + 191 异步),96 个文件含测试 | | 事件系统 | 31 事件类型(health)/ 51 全系统 / 82 发布点 / 15 消费者模块 / Outbox + LISTEN/NOTIFY | @@ -153,6 +153,7 @@ | veepoo-measure 页面空白(useRef is not defined) | [[miniprogram]] 原生页面桥接 | TSX 文件使用 `useRef` 但仅 `import React from 'react'` 未解构导入 | **已修复:** 改为 `import React, { useRef } from 'react'`(2026-05-30) | | M2 设备扫描不到(名称匹配过严) | [[miniprogram]] 原生页面扫描 | 过滤条件 `name.indexOf('M2')` 过严,设备可能广播为 VPM/VEEPOO | **已修复:** 放宽匹配 M2/VPM/VEEPOO 三种前缀(2026-05-30) | | M2 设备认证超时(3 层根因) | [[miniprogram]] 原生页面认证 | **根因链**:①连接回调 `errno:0` 在第 1 次回调就匹配,认证在特征值订阅前发送 → 修复为只匹配 `connection:true`;②`veepooWeiXinSDKNotifyMonitorValueChange` 在 `onLoad` 注册时内部调用 `wx.notifyBLECharacteristicValueChange`,适配器未初始化 → `not init` 错误,改到 `connection:true` 后注册;③认证结果字段检查错误:代码检查 `VPDevicepassword`(值="0000")而非 `VPDeviceAck`(值="successfulVerification") | **已修复:** 三层修复 — connection:true 唯一匹配 + 监听器时序 + VPDeviceAck 字段(2026-05-30) | +| Veepoo 上传按钮无响应(无日志无报错) | [[miniprogram]] veepoo-measure | `handleUpload` 中 `if (!patient) return;` 静默退出,`currentPatient` 从 auth store 恢复可能为 null(原生页返回后) | **已修复:** patientId 增加 URL 参数 fallback + 每个 early return 添加 console.warn + Taro.showToast 用户提示 + 上传按钮 disabled/loading 状态(2026-05-31) | ## 模块导航