feat(mp): Veepoo M2 BLE 管线扩展 — 精准睡眠数据 + 自动测量 + UI 重构

- 新增 VeepooBridge API:精准睡眠读取(readPreciseSleepData)、B3自动测量配置
  (readAutoTestConfig/setAutoTestConfig)、开关设置(setAutoHeartRate/BP/Temp)、
  体温自动数据读取(readAutoTemperatureData),共 10 个新 API
- 新增 SDK 事件类型:SDK_EVENT_SLEEP(4)、SDK_EVENT_AUTO_TEST(54)
- VeepooPipeline 新增:readSleepData/readAllSleepData(enableAutoMeasurement
  睡眠数据 Promise 化读取 + 自动测量一键开启
- VeepooHistoryReader 新增:uploadSleepReadings 睡眠数据上传
- stores/veepoo.ts 实装:注册 onSleepData 回调、syncHistory 实际读取+上传、
  readSleepData 状态管理、enableAutoMeasurement、连接后自动触发三件事
- 原生页面(native/pkg-veepoo):_onReady 后自动读取 3 天睡眠 + 开启自动测量,
  新增 _readSleepData/_handleSleepEvent/_enableAutoMeasurement
- UI 重构:测量页药丸式选择器+SVG 圆环仪表盘+健康评估标签
- 数据上传页:2 列结果卡片网格+彩色条标识+睡眠数据卡片(★评分+总时长)
- 修复上传按钮无响应 bug:patientId 增加 URL fallback + 错误提示不再静默
- 设计原型:docs/design/veepoo-measure-prototype.html(4 状态预览)
This commit is contained in:
iven
2026-05-31 21:48:06 +08:00
parent 6d073840aa
commit 92ffd8cecb
14 changed files with 3419 additions and 603 deletions

View File

@@ -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);
}
},
});

View File

@@ -1,5 +1,6 @@
<!--
Veepoo M2 原生小程序页面 — 连接 + 测量
设计原型: docs/design/veepoo-measure-prototype.html
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
-->
@@ -26,7 +27,7 @@
</view>
<view wx:if="{{hasResults}}" class="connect-back">
<view class="btn-secondary" bindtap="handleBack">查看测量结果并返回</view>
<view class="btn-text" bindtap="handleBack">查看测量结果并返回</view>
</view>
</view>
</block>
@@ -51,82 +52,92 @@
<block wx:elif="{{phase === 'ready'}}">
<view class="measure-page">
<!-- 设备状态栏 -->
<view class="header">
<view class="header-device">
<view class="header-dot header-dot--on"></view>
<text class="header-name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="header-battery">{{batteryLevel}}%</text>
<view class="device-bar">
<view class="device-bar__left">
<view class="device-bar__dot"></view>
<text class="device-bar__name">{{deviceName}}</text>
<text wx:if="{{batteryLevel !== null}}" class="device-bar__battery">{{batteryLevel}}%</text>
</view>
<text class="header-disconnect" bindtap="handleDisconnect">断开</text>
<view class="device-bar__disconnect" bindtap="handleDisconnect">断开</view>
</view>
<!-- 指标选择器 — H1 修复:用 results[type] 替代 measureStates -->
<view class="selector">
<!-- 指标选择器 — 药丸式 -->
<scroll-view class="selector" scroll-x enhanced show-scrollbar="{{false}}">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="selector-item {{selectedType === item.type ? 'selector-item--active' : ''}} {{results[item.type] ? 'selector-item--done' : ''}}"
class="selector__pill {{selectedType === item.type ? 'selector__pill--active' : ''}} {{results[item.type] ? 'selector__pill--done' : ''}}"
data-type="{{item.type}}"
bindtap="handleSelectType"
>
<text class="selector-icon" style="color: {{item.color}}">{{item.icon}}</text>
<text class="selector-label">{{item.label}}</text>
<view wx:if="{{results[item.type]}}" class="selector-check" style="background-color: {{item.color}}">✓</view>
<view class="selector__icon-wrap" style="background: {{item.color}}">
<text class="selector__icon">{{item.icon}}</text>
</view>
<text class="selector__label">{{item.label}}</text>
</view>
</view>
</scroll-view>
<!-- 仪表盘区域 — C4 修复:用 data 中预计算的 selectedIcon/selectedColor -->
<view class="gauge">
<view class="gauge-circle">
<!-- 空闲 -->
<block wx:if="{{measurePhase === 'idle'}}">
<text class="gauge-icon" style="color: {{selectedColor}}">{{selectedIcon}}</text>
<text class="gauge-hint">点击下方按钮开始测量{{selectedLabel}}</text>
</block>
<!-- 测量中 -->
<block wx:elif="{{measurePhase === 'measuring'}}">
<text wx:if="{{measureDisplayValue}}" class="gauge-value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text wx:else class="gauge-loading">测量中...</text>
<text wx:if="{{measureDisplayValue}}" class="gauge-unit">{{selectedUnit}}</text>
</block>
<!-- 成功 -->
<block wx:elif="{{measurePhase === 'success'}}">
<text class="gauge-value" style="color: #22C55E">{{measureDisplayValue}}</text>
<text class="gauge-unit">{{selectedUnit}}</text>
</block>
<!-- 错误 -->
<block wx:elif="{{measurePhase === 'error'}}">
<text class="gauge-err">!</text>
<text class="gauge-err-text">{{measureError}}</text>
</block>
<!-- 仪表盘区域 -->
<view class="gauge-section">
<view class="gauge {{measurePhase === 'measuring' ? 'gauge--measuring' : ''}}">
<!-- SVG 圆环 -->
<view class="gauge__ring-wrap">
<view class="gauge__ring-bg"></view>
<view class="gauge__ring-progress" style="background: conic-gradient({{selectedColor}} {{measureProgress * 3.6}}deg, #E8E2DC 0deg);"></view>
<view class="gauge__center">
<!-- 空闲 -->
<block wx:if="{{measurePhase === 'idle'}}">
<text class="gauge__icon-lg" style="color: {{selectedColor}}">{{selectedIcon}}</text>
<text class="gauge__hint">点击下方按钮开始</text>
</block>
<!-- 测量中 -->
<block wx:elif="{{measurePhase === 'measuring'}}">
<text wx:if="{{measureDisplayValue}}" class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text wx:else class="gauge__loading">测量中...</text>
<text wx:if="{{measureDisplayValue}}" class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 成功 -->
<block wx:elif="{{measurePhase === 'success'}}">
<text class="gauge__value" style="color: {{selectedColor}}">{{measureDisplayValue}}</text>
<text class="gauge__unit">{{selectedUnit}}</text>
</block>
<!-- 错误 -->
<block wx:elif="{{measurePhase === 'error'}}">
<text class="gauge__err">!</text>
<text class="gauge__err-text">{{measureError}}</text>
</block>
</view>
</view>
</view>
<!-- 进度条 -->
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="gauge-progress-bar">
<view class="gauge-progress-fill" style="width: {{measureProgress}}%"></view>
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="progress-bar">
<view class="progress-bar__fill" style="width: {{measureProgress}}%; background: {{selectedColor}};"></view>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<text class="disclaimer-text">本数据由智能手环采集,仅供健康趋势参考,不作为医诊断依据</text>
<text class="disclaimer__text">测量数据仅供参考,不作为医诊断依据。如有不适请及时就医。</text>
</view>
<!-- 操作按钮 -->
<view class="actions">
<block wx:if="{{measurePhase === 'idle'}}">
<view class="btn-primary btn-large" bindtap="handleStartMeasure">开始测量{{selectedLabel}}</view>
</block>
<block wx:elif="{{measurePhase === 'measuring'}}">
<view class="btn-secondary btn-large" bindtap="handleCancelMeasure">停止测量</view>
</block>
<block wx:elif="{{measurePhase === 'success'}}">
<view class="actions-row">
<view class="btn-secondary" bindtap="handleResetResult">重新测量</view>
<view class="btn-primary" bindtap="handleBack">完成并返回</view>
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">
开始测量{{selectedLabel}}
</view>
</block>
<block wx:elif="{{measurePhase === 'measuring'}}">
<view class="btn btn--secondary" bindtap="handleCancelMeasure">停止测量</view>
<view class="btn btn--text" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'success'}}">
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleResetResult">重新测量</view>
<view class="btn btn--secondary" bindtap="handleBack">完成并查看结果</view>
</block>
<block wx:elif="{{measurePhase === 'error'}}">
<view class="btn-primary btn-large" bindtap="handleStartMeasure">重新测量</view>
<view class="btn btn--primary" style="background: {{selectedColor}}; box-shadow: 0 4px 16px rgba(0,0,0,0.15);" bindtap="handleStartMeasure">重新测量</view>
</block>
</view>
</view>

View File

@@ -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; }

View File

@@ -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);
}
}

View File

@@ -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<string, number>): { 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, number>): 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<Record<string, NativeMeasureResult>>({});
const [uploadStatus, setUploadStatus] = React.useState<string>('');
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
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 (
<PageShell padding="none" className={`vm-page ${modeClass}`}>
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
{hasResults ? (
<View className="vm-results">
{Object.entries(results).map(([type, r]) => (
<View key={type} className="vm-results__item">
<Text className="vm-results__type">{type}</Text>
<Text className="vm-results__value">{JSON.stringify(r.values)}</Text>
</View>
))}
<Text className="vm-connect__hint">{uploadStatus}</Text>
<View className="vm-connect__error-btn">
<PrimaryButton onClick={handleUpload}></PrimaryButton>
</View>
{hasResults ? (
<View className="vm-upload">
{/* 页面标题 */}
<View className="vm-upload__header">
<Text className="vm-upload__title"></Text>
<Text className="vm-upload__subtitle">Veepoo M2 · </Text>
</View>
) : (
{/* 结果卡片网格 */}
<View className="vm-results-grid">
{METRIC_CONFIG.map((metric) => {
const result = results[metric.type];
if (result) {
const assessment = assessHealth(metric.type, result.values);
return (
<View
key={metric.type}
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<View className="vm-result-card__row">
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
<Text className="vm-result-card__unit">{metric.unit}</Text>
</View>
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
<Text> {assessment.text}</Text>
</View>
</View>
);
}
// 未测量占位
return (
<View
key={metric.type}
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<Text className="vm-result-card__placeholder"></Text>
</View>
);
})}
{/* 睡眠数据卡片 */}
{sleepData.length > 0 && (
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
<Text className="vm-result-card__label">{sleepData.length} </Text>
{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 (
<View key={idx} className="vm-sleep-row">
<Text className="vm-sleep-row__day">{dayLabel}</Text>
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
<View className="vm-sleep-row__quality">
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
</View>
</View>
);
})}
<View className="vm-result-card__tag vm-result-card__tag--normal">
<Text> </Text>
</View>
</View>
)}
</View>
{/* 底部上传播区 */}
<View className="vm-upload-footer">
<Text className="vm-upload-footer__hint"></Text>
<View className="vm-upload-footer__btn">
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
{uploadStatus === 'uploading'
? '上传中...'
: uploadStatus === 'success'
? '✓ 已上传'
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''}`}
</PrimaryButton>
</View>
{measuredAt && <Text className="vm-upload-footer__time">{measuredAt}</Text>}
</View>
</View>
) : (
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
<Text className="vm-connect__hint">...</Text>
)}
</View>
</View>
)}
</PageShell>
);
}

View File

@@ -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<string, unknown>;
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<void> {
return new Promise((resolve) => {
veepooBle.veepooWeiXinSDKStopSearchBleManager(() => resolve());
});
}
/** 连接设备 — 传入完整设备对象 */
export function connectDevice(device: unknown): Promise<VeepooConnectionResult> {
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<void> {
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);
}

View File

@@ -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<number> {
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<void> {
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<typeof setTimeout> | 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<void> {
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<number> {
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;
}
}
}

View File

@@ -0,0 +1,588 @@
/**
* Veepoo 管线 — SDK 事件路由 + 连接编排 + 测量 Promise 封装
*
* 职责:
* 1. 连接流程编排:扫描 → 连接 → 注册监听 → 认证 → 就绪
* 2. SDK 事件路由registerDataListener 按 type 分发
* 3. 测量 Promise 化startMeasure(type) → Promise<MeasureResult>
*/
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<typeof setTimeout>;
lastValue: number | null;
lastValues: Record<string, number>;
settleTimer: ReturnType<typeof setTimeout> | null;
}
/** SDK type 到 MeasureType 的映射 */
const SDK_TYPE_TO_MEASURE: Record<number, MeasureType> = {
[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<string, unknown>) => 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<number, (sleep: SleepReading | null) => void> = new Map();
private sleepTimeouts: Map<number, ReturnType<typeof setTimeout>> = 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<string> {
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<string, unknown>)?.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<string, unknown>).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<unknown | null> {
return new Promise((resolve) => {
let found: unknown = null;
const upper = targetName.toUpperCase();
startScan((device) => {
const d = device as Record<string, unknown>;
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<boolean> {
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<string, unknown>): Record<string, number> | 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<MeasureResult> {
if (this.pending) {
throw new Error(`正在测量 ${this.pending.type},请等待完成`);
}
if (!this.isConnected) {
throw new Error('设备未连接');
}
return new Promise<MeasureResult>((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<string, number>): 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<SleepReading | null> {
if (!this.isConnected) {
return Promise.reject(new Error('设备未连接'));
}
return new Promise<SleepReading | null>((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<SleepReading[]> {
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<string, unknown>);
// 通知回调
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<string, unknown>): 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<void> {
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<void> {
return new Promise((r) => setTimeout(r, ms));
}
function getMeasureTimeout(type: MeasureType): number {
const timeouts: Record<MeasureType, number> = {
heart_rate: 60_000,
blood_oxygen: 60_000,
blood_pressure: 120_000,
temperature: 60_000,
pressure: 90_000,
};
return timeouts[type];
}

View File

@@ -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';

View File

@@ -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<MeasureType, MeasureConfig> = {
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<string, number>;
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;
}

View File

@@ -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<MeasureType, MeasureStatus> {
const states = {} as Record<MeasureType, MeasureStatus>;
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<MeasureType, MeasureStatus>;
// 历史
historySync: HistorySyncState;
// 睡眠
sleepData: SleepReading[];
sleepLoading: boolean;
// Actions
connect: (targetName?: string) => Promise<void>;
disconnect: () => Promise<void>;
startMeasure: (type: MeasureType) => Promise<MeasureResult>;
cancelMeasure: () => void;
syncHistory: (patientId: string) => Promise<void>;
readSleepData: () => Promise<SleepReading[]>;
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<VeepooState>((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<string, unknown>): 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<void> {
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();
});
}