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:
479
apps/miniprogram/native/pkg-veepoo/index.js
Normal file
479
apps/miniprogram/native/pkg-veepoo/index.js
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
6
apps/miniprogram/native/pkg-veepoo/index.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"navigationBarTitleText": "M2 手环测量",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F5F5F4"
|
||||
}
|
||||
133
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal file
133
apps/miniprogram/native/pkg-veepoo/index.wxml
Normal 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>
|
||||
355
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal file
355
apps/miniprogram/native/pkg-veepoo/index.wxss
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user