fix(mp): Veepoo M2 BLE 审计 C1-C5/H1-H6 全量修复

CRITICAL 修复:
- C1: 体温测量传 { switch: boolean } 参数 + 停止指令
- C2: uploadReadings 使用正确 NormalizedReading 类型替代 as any
- C3: navigatedRef 防重入避免 React 18 Strict Mode 双触发导航
- C4: WXML gauge 空闲态用 data 预计算值替代 findIndex+匿名函数
- C5: _onReady 清除 _authTimeout 防止 Timer 泄漏

HIGH 修复:
- H1: WXML 用 results[type] 替代未声明的 measureStates
- H2: handleConnect 添加 _connecting 防重入保护
- H4: 连接回调兼容 errno:0 / errCode:0 fallback
- H5: _formatValues 零值合法显示(!== undefined && !== null)

MEDIUM:
- Storage key 添加 hms: 命名空间前缀防冲突
This commit is contained in:
iven
2026-05-30 13:11:49 +08:00
parent 432c5d96f2
commit a86219c8a0
7 changed files with 1484 additions and 0 deletions

View File

@@ -0,0 +1,479 @@
/**
* Veepoo M2 原生小程序页面 — 连接 + 测量
*
* 完全脱离 Taro 框架,直接使用微信原生 API + Veepoo SDK。
* 连接流程严格对齐官方 Demo
* scan → stopScan → connect(callback, 等待 connection:true)
* → registerDataListener → delay 500ms → authenticate
* → 轮询 deviceChipStatus → ready
*/
// eslint-disable-next-line no-undef
const { veepooBle, veepooFeature, veepooLogger } = require('./libs/veepoo-sdk');
// ── 常量 ──
var SDK_EVENT_AUTH = 1;
var SDK_EVENT_BATTERY = 2;
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 MEASURE_TYPES = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', icon: '♥', color: '#EF4444', sdkType: SDK_EVENT_HEART_RATE },
{ type: 'blood_oxygen', label: '血氧', unit: '%', icon: 'O₂', color: '#3B82F6', sdkType: SDK_EVENT_BLOOD_OXYGEN },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', icon: '↕', color: '#8B5CF6', sdkType: SDK_EVENT_BLOOD_PRESSURE },
{ type: 'temperature', label: '体温', unit: '°C', icon: 'T', color: '#F59E0B', sdkType: SDK_EVENT_TEMPERATURE },
{ type: 'pressure', label: '压力', unit: '', icon: '~', color: '#6366F1', sdkType: SDK_EVENT_PRESSURE },
];
var MEASURE_TIMEOUTS = {
heart_rate: 60000,
blood_oxygen: 60000,
blood_pressure: 120000,
temperature: 60000,
pressure: 90000,
};
var MEASURE_SETTLE_DELAY = 1500;
function _findConfig(type) {
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].type === type) return MEASURE_TYPES[i];
}
return MEASURE_TYPES[0];
}
// ── Page ──
// eslint-disable-next-line no-undef
Page({
data: {
phase: 'idle',
deviceId: '',
deviceName: 'M2',
batteryLevel: null,
error: '',
selectedType: 'heart_rate',
selectedIcon: '♥',
selectedColor: '#EF4444',
selectedLabel: '心率',
selectedUnit: 'bpm',
measureTypes: MEASURE_TYPES,
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: {},
hasResults: false,
},
_authTimer: null,
_authTimeout: null,
_scanTimer: null,
_scanFound: null,
_measureTimer: null,
_settleTimer: null,
_lastValues: null,
_connected: false,
_eventChannel: null,
_connecting: false,
// ── 生命周期 ──
onLoad: function () {
// eslint-disable-next-line no-undef
this._eventChannel = this.getOpenerEventChannel();
// eslint-disable-next-line no-undef
wx.setNavigationBarTitle({ title: 'M2 手环测量' });
this._updateSelectedDisplay('heart_rate');
},
onUnload: function () {
this._cleanup();
},
_cleanup: function () {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
if (this._scanTimer) { clearTimeout(this._scanTimer); this._scanTimer = null; }
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._connecting = false;
if (this._connected) {
try { veepooBle.veepooWeiXinSDKloseBluetoothAdapterManager(function () {}); } catch (e) {
// eslint-disable-next-line no-undef
console.warn('[veepoo-native] 断开异常:', e);
}
this._connected = false;
}
},
_updateSelectedDisplay: function (type) {
var cfg = _findConfig(type);
this.setData({
selectedType: type,
selectedIcon: cfg.icon,
selectedColor: cfg.color,
selectedLabel: cfg.label,
selectedUnit: cfg.unit,
});
},
// ── 连接流程(严格对齐官方 Demo ──
handleConnect: function () {
// H2 防重入
if (this.data.phase !== 'idle' && this.data.phase !== 'error' && this.data.phase !== 'disconnected') return;
if (this._connecting) return;
this._connecting = true;
this.setData({ phase: 'scanning', error: '' });
veepooLogger.setLevel(0);
var self = this;
self._scanFound = null;
veepooBle.veepooWeiXinSDKStartScanDeviceAndReceiveScanningDevice(function (res) {
var device = Array.isArray(res) ? res[0] : res;
if (!device) return;
var name = (device.localName || device.name || '').toUpperCase();
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 扫描到:', name, device.deviceId || device.mac);
if (name.indexOf('M2') !== -1 && !self._scanFound) {
self._scanFound = device;
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {
self._doConnect(device);
});
}
});
this._scanTimer = setTimeout(function () {
if (!self._scanFound) {
veepooBle.veepooWeiXinSDKStopSearchBleManager(function () {});
self._connecting = false;
self.setData({ phase: 'error', error: '未找到 M2 设备,请确保手环已开机' });
}
}, 10000);
},
_doConnect: function (device) {
this.setData({ phase: 'connecting' });
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接设备:', device.deviceId || device.mac);
var self = this;
veepooBle.veepooWeiXinSDKBleConnectionServicesCharacteristicsNotifyManager(device, function (result) {
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 连接回调:', JSON.stringify(result));
// C4 修复:按官方 Demo 检查 connection:true同时兼容 errno:0
if (result.connection === true || result.errno === 0 || result.errCode === 0) {
self._connected = true;
self._connecting = false;
self.setData({
deviceId: device.deviceId || device.mac || '',
});
self._registerListeners();
// eslint-disable-next-line no-undef
setTimeout(function () {
veepooFeature.veepooBlePasswordCheckManager();
self.setData({ phase: 'authenticating' });
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证指令已发送');
}, 500);
self._authTimer = setInterval(function () {
try {
// eslint-disable-next-line no-undef
var status = wx.getStorageSync('deviceChipStatus');
if (status === 'successfulVerification' || status === 'passTheVerification') {
clearInterval(self._authTimer);
self._authTimer = null;
self._onReady();
}
} catch (_) { /* ignore */ }
}, 500);
self._authTimeout = setTimeout(function () {
if (self._authTimer) {
clearInterval(self._authTimer);
self._authTimer = null;
self._connecting = false;
self.setData({ phase: 'error', error: '设备认证超时,请重新连接' });
}
}, 8000);
}
});
},
_registerListeners: function () {
var self = this;
veepooBle.veepooWeiXinSDKNotifyMonitorValueChange(function (data) {
self._handleSdkEvent(data);
});
veepooBle.veepooWeiXinSDKBLEConnectionStateChangeManager(function (res) {
if (!res.connected) {
self._connected = false;
self._connecting = false;
self._cancelPendingMeasure();
self.setData({ phase: 'disconnected' });
}
});
},
_onReady: function () {
// C5 修复:清除 authTimeout 防止泄漏
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._connecting = false;
// eslint-disable-next-line no-undef
console.log('[veepoo-native] 认证成功,设备就绪');
this.setData({ phase: 'ready' });
veepooFeature.veepooReadElectricQuantityManager();
},
// ── SDK 事件路由 ──
_handleSdkEvent: function (data) {
if (!data || data.type === undefined) return;
var type = data.type;
if (type === SDK_EVENT_AUTH) {
var password = (data.content || {}).VPDevicepassword;
if (password === 'passTheVerification' || password === 'successfulVerification') {
if (this._authTimer) { clearInterval(this._authTimer); this._authTimer = null; }
if (this._authTimeout) { clearTimeout(this._authTimeout); this._authTimeout = null; }
this._onReady();
}
return;
}
if (type === SDK_EVENT_BATTERY) {
var pct = (data.content || {}).VPDeviceElectricPercent;
if (pct !== undefined && pct !== null) {
this.setData({ batteryLevel: Number(pct) });
}
return;
}
for (var i = 0; i < MEASURE_TYPES.length; i++) {
if (MEASURE_TYPES[i].sdkType === type) {
this._handleMeasureEvent(MEASURE_TYPES[i].type, data);
return;
}
}
},
// ── 测量流程 ──
handleSelectType: function (e) {
var type = e.currentTarget.dataset.type;
if (this.data.measurePhase === 'measuring') return;
this._updateSelectedDisplay(type);
this.setData({ measureError: '' });
},
handleStartMeasure: function () {
var type = this.data.selectedType;
if (this.data.measurePhase === 'measuring') return;
if (!this._connected) {
this.setData({ measureError: '设备未连接' });
return;
}
var self = this;
self._lastValues = null;
self.setData({
measurePhase: 'measuring',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
});
self._sendMeasureCommand(type, true);
self._measureTimer = setTimeout(function () {
self._onMeasureError('测量超时,请重试');
}, MEASURE_TIMEOUTS[type] || 60000);
},
handleCancelMeasure: function () {
this._cancelPendingMeasure();
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
});
},
handleDisconnect: function () {
this._cleanup();
this.setData({ phase: 'idle', deviceId: '', batteryLevel: null, error: '' });
},
handleBack: function () {
var results = this.data.results;
if (Object.keys(results).length > 0) {
try {
// eslint-disable-next-line no-undef
wx.setStorageSync('hms:veepoo_measure_results', JSON.stringify(results));
} catch (_) { /* ignore */ }
}
if (this._eventChannel) {
this._eventChannel.emit('measureComplete', { results: results, count: Object.keys(results).length });
}
// eslint-disable-next-line no-undef
wx.navigateBack({ delta: 1 });
},
handleResetResult: function () {
var type = this.data.selectedType;
var newResults = Object.assign({}, this.data.results);
delete newResults[type];
this.setData({
measurePhase: 'idle',
measureProgress: 0,
measureDisplayValue: '',
measureError: '',
results: newResults,
hasResults: Object.keys(newResults).length > 0,
});
},
// ── 测量事件处理 ──
_handleMeasureEvent: function (type, data) {
if (this.data.selectedType !== type || this.data.measurePhase !== 'measuring') return;
var content = data.content || {};
var self = this;
if (content.deviceBusy === true) { self._onMeasureError('设备正忙,请稍后重试'); return; }
if (content.notWear === true || data.state === 6) { self._onMeasureError('请将手环佩戴到手腕上'); return; }
if (data.state === 7) { self._onMeasureError('设备正在充电'); return; }
if (data.state === 8) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 2) { self._onMeasureError('设备电量不足'); return; }
if (type === 'pressure' && data.ack === 3) { self._onMeasureError('设备正在测量其他数据'); return; }
if (type === 'pressure' && data.ack === 4) { self._onMeasureError('佩戴检测未通过'); return; }
var values = self._extractValues(type, content);
if (!values) return;
self._lastValues = values;
var displayVal = self._formatValues(type, values);
var progress = data.Progress !== undefined ? data.Progress : 0;
self.setData({
measureDisplayValue: displayVal,
measureProgress: Math.max(progress, 0),
});
if (progress >= 100) {
self._onMeasureSuccess(type, values);
return;
}
if (!self._settleTimer) {
self._settleTimer = setTimeout(function () {
if (self._lastValues && self.data.measurePhase === 'measuring') {
self._onMeasureSuccess(type, self._lastValues);
}
}, MEASURE_SETTLE_DELAY);
}
},
_extractValues: function (type, content) {
switch (type) {
case 'heart_rate': {
var hr = Number(content.heartRate);
return (hr >= 30 && hr <= 250) ? { heart_rate: hr } : null;
}
case 'blood_oxygen': {
var bo = Number(content.bloodOxygen);
return (bo >= 70 && bo <= 100) ? { blood_oxygen: bo } : null;
}
case 'blood_pressure': {
var high = Number(content.bloodPressureHigh);
var low = Number(content.bloodPressureLow);
return (high > 0 && low > 0) ? { systolic: high, diastolic: low } : null;
}
case 'temperature': {
var temp = Number(content.bodyTemperature);
return (temp > 30 && temp < 45) ? { temperature: temp } : null;
}
case 'pressure': {
var p = Number(content.pressure);
return (p >= 0 && p <= 100) ? { pressure: p } : null;
}
default: return null;
}
},
_formatValues: function (type, values) {
if (type === 'blood_pressure') {
return (values.systolic ?? '--') + '/' + (values.diastolic ?? '--');
}
var v = Object.values(values)[0];
// H5 修复:零值合法,仅 undefined/null 显示占位符
return (v !== undefined && v !== null) ? String(v) : '--';
},
_onMeasureSuccess: function (type, values) {
this._cancelPendingMeasure();
var result = { type: type, values: values, measuredAt: Date.now() };
var newResults = Object.assign({}, this.data.results);
newResults[type] = result;
this.setData({
measurePhase: 'success',
measureProgress: 100,
measureDisplayValue: this._formatValues(type, values),
results: newResults,
hasResults: true,
});
if (this._eventChannel) {
this._eventChannel.emit('measureResult', result);
}
},
_onMeasureError: function (msg) {
this._cancelPendingMeasure();
this.setData({ measurePhase: 'error', measureError: msg });
},
_cancelPendingMeasure: function () {
if (this._measureTimer) { clearTimeout(this._measureTimer); this._measureTimer = null; }
if (this._settleTimer) { clearTimeout(this._settleTimer); this._settleTimer = null; }
this._lastValues = null;
var type = this.data.selectedType;
if (type) this._sendMeasureCommand(type, false);
},
_sendMeasureCommand: function (type, on) {
switch (type) {
case 'heart_rate':
veepooFeature.veepooSendHeartRateTestSwitchManager({ switch: !!on });
break;
case 'blood_oxygen':
veepooFeature.veepooSendBloodOxygenControlDataManager({ switch: on ? 'start' : 'stop' });
break;
case 'blood_pressure':
veepooFeature.veepooSendReadUniversalBloodPressureDataManager({ switch: on ? 'start' : 'stop' });
break;
// C1 修复:体温测量传 { switch: boolean } 参数,停止时也调用
case 'temperature':
veepooFeature.veepooSendTemperatureMeasurementSwitchManager({ switch: !!on });
break;
case 'pressure':
veepooFeature.veepooSendPressureTestManager({ switch: !!on });
break;
}
},
});

View File

@@ -0,0 +1,6 @@
{
"navigationBarTitleText": "M2 手环测量",
"navigationBarBackgroundColor": "#FFFFFF",
"navigationBarTextStyle": "black",
"backgroundColor": "#F5F5F4"
}

View File

@@ -0,0 +1,133 @@
<!--
Veepoo M2 原生小程序页面 — 连接 + 测量
完全匹配 SDK 官方 Demo 流程,不依赖 Taro
-->
<!-- ═══ 未连接 / 错误 / 断开 ═══ -->
<block wx:if="{{phase === 'idle' || phase === 'error' || phase === 'disconnected'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">M2 手环健康测量</text>
<text class="connect-hint">请确保手环已开机且蓝牙已开启</text>
<view wx:if="{{error}}" class="connect-error">
<text class="connect-error-text">{{error}}</text>
</view>
<view class="connect-btn-wrap">
<view class="btn-primary" bindtap="handleConnect">
{{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}}
</view>
</view>
<view wx:if="{{hasResults}}" class="connect-back">
<view class="btn-secondary" bindtap="handleBack">查看测量结果并返回</view>
</view>
</view>
</block>
<!-- ═══ 连接中(扫描/连接/认证) ═══ -->
<block wx:elif="{{phase === 'scanning' || phase === 'connecting' || phase === 'authenticating'}}">
<view class="connect-screen">
<view class="connect-anim">
<view class="connect-ring connect-ring--active"></view>
<view class="connect-center">
<text class="connect-bt">BT</text>
</view>
</view>
<text class="connect-title">
{{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}}
</text>
<text class="connect-hint">请确保手环已开机且靠近手机</text>
</view>
</block>
<!-- ═══ 就绪 + 测量 ═══ -->
<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>
<text class="header-disconnect" bindtap="handleDisconnect">断开</text>
</view>
<!-- 指标选择器 — H1 修复:用 results[type] 替代 measureStates -->
<view class="selector">
<view
wx:for="{{measureTypes}}"
wx:key="type"
class="selector-item {{selectedType === item.type ? 'selector-item--active' : ''}} {{results[item.type] ? 'selector-item--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>
</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>
<!-- 进度条 -->
<view wx:if="{{measurePhase === 'measuring' && measureProgress > 0}}" class="gauge-progress-bar">
<view class="gauge-progress-fill" style="width: {{measureProgress}}%"></view>
</view>
</view>
<!-- 免责声明 -->
<view class="disclaimer">
<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>
</block>
<block wx:elif="{{measurePhase === 'error'}}">
<view class="btn-primary btn-large" bindtap="handleStartMeasure">重新测量</view>
</block>
</view>
</view>
</block>

View File

@@ -0,0 +1,355 @@
/* Veepoo M2 原生页面样式 */
page {
background: #F5F5F4;
min-height: 100vh;
}
/* ═══ 连接屏幕 ═══ */
.connect-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 40rpx;
}
.connect-anim {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 48rpx;
}
.connect-ring {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
border-radius: 50%;
border: 4rpx solid #D6D3D1;
}
.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; }
}
.connect-center {
position: absolute;
top: 50%; left: 50%;
transform: translate(-50%, -50%);
width: 80rpx; height: 80rpx;
border-radius: 50%;
background: #292524;
display: flex;
align-items: center;
justify-content: center;
}
.connect-bt {
color: #FAFAF9;
font-size: 28rpx;
font-weight: 600;
}
.connect-title {
font-size: 36rpx;
font-weight: 600;
color: #1C1917;
margin-bottom: 16rpx;
}
.connect-hint {
font-size: 26rpx;
color: #78716C;
margin-bottom: 48rpx;
}
.connect-error {
background: #FEF2F2;
border-radius: 16rpx;
padding: 24rpx 32rpx;
margin-bottom: 32rpx;
width: 100%;
max-width: 600rpx;
}
.connect-error-text {
font-size: 26rpx;
color: #DC2626;
}
.connect-btn-wrap {
width: 100%;
max-width: 600rpx;
}
.connect-back {
width: 100%;
max-width: 600rpx;
margin-top: 24rpx;
}
/* ═══ 按钮 ═══ */
.btn-primary {
background: #C4623A;
color: #FFFFFF;
font-size: 30rpx;
font-weight: 600;
text-align: center;
padding: 24rpx 0;
border-radius: 16rpx;
}
.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;
}
/* ── Header ── */
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 24rpx;
background: #FFFFFF;
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.header-device {
display: flex;
align-items: center;
gap: 12rpx;
}
.header-dot {
width: 16rpx; height: 16rpx;
border-radius: 50%;
background: #A8A29E;
}
.header-dot--on {
background: #22C55E;
}
.header-name {
font-size: 28rpx;
font-weight: 600;
color: #1C1917;
}
.header-battery {
font-size: 24rpx;
color: #78716C;
}
.header-disconnect {
font-size: 26rpx;
color: #C4623A;
}
/* ── Selector ── */
.selector {
display: flex;
justify-content: space-around;
padding: 24rpx 0;
background: #FFFFFF;
border-radius: 16rpx;
margin-bottom: 24rpx;
}
.selector-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
padding: 12rpx 16rpx;
border-radius: 12rpx;
position: relative;
}
.selector-item--active {
background: #FFF7ED;
}
.selector-item--done::after {
content: '';
}
.selector-icon {
font-size: 40rpx;
}
.selector-label {
font-size: 22rpx;
color: #57534E;
}
.selector-check {
position: absolute;
top: 4rpx; right: 4rpx;
width: 28rpx; height: 28rpx;
border-radius: 50%;
color: #FFFFFF;
font-size: 18rpx;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Gauge ── */
.gauge {
flex: 1;
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 {
font-size: 64rpx;
margin-bottom: 16rpx;
}
.gauge-hint {
font-size: 26rpx;
color: #78716C;
}
.gauge-value {
font-size: 80rpx;
font-weight: 700;
line-height: 1.1;
}
.gauge-loading {
font-size: 30rpx;
color: #78716C;
}
.gauge-err {
font-size: 72rpx;
font-weight: 700;
color: #DC2626;
margin-bottom: 8rpx;
}
.gauge-err-text {
font-size: 26rpx;
color: #DC2626;
}
.gauge-progress-bar {
width: 500rpx;
height: 8rpx;
background: #E7E5E4;
border-radius: 4rpx;
overflow: hidden;
}
.gauge-progress-fill {
height: 100%;
background: #C4623A;
border-radius: 4rpx;
transition: width 0.3s ease;
}
/* ── Assessment ── */
.assessment {
text-align: center;
padding: 16rpx;
}
.assessment-text {
font-size: 26rpx;
color: #16A34A;
}
/* ── Disclaimer ── */
.disclaimer {
text-align: center;
padding: 16rpx 32rpx;
}
.disclaimer-text {
font-size: 22rpx;
color: #A8A29E;
}
/* ── Actions ── */
.actions {
padding: 24rpx 0;
}
/* ── Measure Error ── */
.measure-error {
text-align: center;
padding: 16rpx;
}
.measure-error-text {
font-size: 26rpx;
color: #DC2626;
}

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '健康测量',
});

View File

@@ -0,0 +1,398 @@
// Veepoo 实时测量页样式
.vm-page {
min-height: 100vh;
background: var(--tk-bg-primary);
}
// ── 连接中 ──
.vm-connect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 32px;
&__anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
}
&__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--tk-brand, #3B82F6);
animation: vm-pulse-ring 2s ease-out infinite;
}
&__center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: var(--tk-brand, #3B82F6);
display: flex;
align-items: center;
justify-content: center;
}
&__bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
&__title {
font-size: 18px;
font-weight: 600;
color: var(--tk-text-primary);
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;
}
}
@keyframes vm-pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
// ── 就绪/测量中 ──
.vm-body {
padding: 16px;
}
// ── 设备状态栏 ──
.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;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tk-color-success, #10B981);
&--on { background: var(--tk-color-success, #10B981); }
}
&__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;
}
}
// ── 指标选择器 ──
.vm-selector {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 24px;
&__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;
&--active {
border-color: var(--tk-brand, #3B82F6);
background: var(--tk-bg-tertiary);
}
&--measuring {
opacity: 0.7;
}
&--done {
border-color: var(--tk-color-success, #10B981);
}
}
&__icon {
font-size: 22px;
margin-bottom: 4px;
}
&__label {
font-size: 12px;
color: var(--tk-text-secondary);
}
&__check {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
color: #fff;
font-size: 10px;
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;
}
&__value {
font-size: 48px;
font-weight: 700;
line-height: 1;
}
&__unit {
font-size: 14px;
color: var(--tk-text-secondary);
margin-top: 4px;
}
&__loading {
font-size: 16px;
color: var(--tk-text-secondary);
}
&__error {
display: flex;
flex-direction: column;
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;
font-weight: 500;
}
}
// ── 免责声明 ──
.vm-disclaimer {
text-align: center;
padding: 8px 16px;
margin-bottom: 16px;
&__text {
font-size: 11px;
color: var(--tk-text-quaternary);
line-height: 1.5;
}
}
// ── 操作按钮 ──
.vm-actions {
padding: 0 16px;
&__row {
display: flex;
gap: 12px;
}
}
// ── 测量错误 ──
.vm-measure-error {
text-align: center;
padding: 8px 16px;
margin-top: 12px;
&__text {
font-size: 13px;
color: var(--tk-color-danger, #EF4444);
}
}
// ── 长者模式 ──
.elder-mode {
.vm-selector {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.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;
}
}

View File

@@ -0,0 +1,110 @@
import React from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { uploadReadings } from '@/services/device-sync';
import type { NormalizedReading } from '@/services/ble/types';
import { useElderClass } from '@/hooks/useElderClass';
import PageShell from '@/components/ui/PageShell';
import PrimaryButton from '@/components/ui/PrimaryButton';
import './index.scss';
/** 原生页面返回的测量结果格式 */
interface NativeMeasureResult {
type: string;
values: Record<string, number>;
measuredAt: number;
}
export default function VeepooMeasure() {
const modeClass = useElderClass();
const patient = useAuthStore((s) => s.currentPatient);
const navigatedRef = useRef(false);
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
const [uploadStatus, setUploadStatus] = React.useState<string>('');
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
if (!navigatedRef.current) {
navigatedRef.current = true;
const patientId = patient?.id || '';
// 延迟到下一个微任务,确保页面渲染完成后再跳转
setTimeout(() => {
Taro.navigateTo({
url: `/pkg-veepoo/index?patientId=${patientId}`,
events: {
measureResult: (data: NativeMeasureResult) => {
setResults((prev) => ({ ...prev, [data.type]: data }));
},
measureComplete: (data: { results: Record<string, NativeMeasureResult>; count: number }) => {
if (data.results) setResults(data.results);
},
},
});
}, 50);
}
// 页面恢复时读取原生页面返回的测量结果
useDidShow(() => {
try {
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
if (raw) {
const parsed = JSON.parse(raw) as Record<string, NativeMeasureResult>;
setResults(parsed);
Taro.removeStorageSync('hms:veepoo_measure_results');
}
} catch { /* ignore */ }
});
const handleUpload = async () => {
if (!patient) return;
const allResults = Object.values(results);
if (allResults.length === 0) return;
setUploadStatus('上传中...');
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('上传成功');
Taro.showToast({ title: '数据已上传', icon: 'success' });
} catch {
setUploadStatus('上传失败');
Taro.showToast({ title: '上传失败', icon: 'none' });
}
};
const hasResults = Object.keys(results).length > 0;
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>
</View>
) : (
<Text className="vm-connect__hint">...</Text>
)}
</View>
</PageShell>
);
}