diff --git a/apps/miniprogram/native/pkg-veepoo/index.js b/apps/miniprogram/native/pkg-veepoo/index.js new file mode 100644 index 0000000..ecebd62 --- /dev/null +++ b/apps/miniprogram/native/pkg-veepoo/index.js @@ -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; + } + }, +}); diff --git a/apps/miniprogram/native/pkg-veepoo/index.json b/apps/miniprogram/native/pkg-veepoo/index.json new file mode 100644 index 0000000..f422545 --- /dev/null +++ b/apps/miniprogram/native/pkg-veepoo/index.json @@ -0,0 +1,6 @@ +{ + "navigationBarTitleText": "M2 手环测量", + "navigationBarBackgroundColor": "#FFFFFF", + "navigationBarTextStyle": "black", + "backgroundColor": "#F5F5F4" +} diff --git a/apps/miniprogram/native/pkg-veepoo/index.wxml b/apps/miniprogram/native/pkg-veepoo/index.wxml new file mode 100644 index 0000000..4568ac8 --- /dev/null +++ b/apps/miniprogram/native/pkg-veepoo/index.wxml @@ -0,0 +1,133 @@ + + + + + + + + + BT + + + M2 手环健康测量 + 请确保手环已开机且蓝牙已开启 + + + {{error}} + + + + + {{phase === 'error' ? '重新连接' : phase === 'disconnected' ? '重新连接' : '连接 M2 手环'}} + + + + + 查看测量结果并返回 + + + + + + + + + + + BT + + + + {{phase === 'scanning' ? '正在搜索 M2 手环...' : phase === 'connecting' ? '正在连接...' : '正在认证...'}} + + 请确保手环已开机且靠近手机 + + + + + + + + + + + {{deviceName}} + {{batteryLevel}}% + + 断开 + + + + + + {{item.icon}} + {{item.label}} + + + + + + + + + + {{selectedIcon}} + 点击下方按钮开始测量{{selectedLabel}} + + + + {{measureDisplayValue}} + 测量中... + {{selectedUnit}} + + + + {{measureDisplayValue}} + {{selectedUnit}} + + + + ! + {{measureError}} + + + + + + + + + + + 本数据由智能手环采集,仅供健康趋势参考,不作为医疗诊断依据 + + + + + + 开始测量{{selectedLabel}} + + + 停止测量 + + + + 重新测量 + 完成并返回 + + + + 重新测量 + + + + diff --git a/apps/miniprogram/native/pkg-veepoo/index.wxss b/apps/miniprogram/native/pkg-veepoo/index.wxss new file mode 100644 index 0000000..817f4ae --- /dev/null +++ b/apps/miniprogram/native/pkg-veepoo/index.wxss @@ -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; +} diff --git a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.config.ts b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.config.ts new file mode 100644 index 0000000..9fa6a93 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '健康测量', +}); diff --git a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss new file mode 100644 index 0000000..ce116d2 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss @@ -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; + } +} diff --git a/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx new file mode 100644 index 0000000..a5261f6 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx @@ -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; + measuredAt: number; +} + +export default function VeepooMeasure() { + const modeClass = useElderClass(); + const patient = useAuthStore((s) => s.currentPatient); + const navigatedRef = useRef(false); + const [results, setResults] = React.useState>({}); + const [uploadStatus, setUploadStatus] = React.useState(''); + + // 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; 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; + 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 ( + + + + + BT + + M2 手环健康测量 + + {hasResults ? ( + + {Object.entries(results).map(([type, r]) => ( + + {type} + {JSON.stringify(r.values)} + + ))} + {uploadStatus} + + 上传测量数据 + + + ) : ( + 即将跳转到设备测量页面... + )} + + + ); +}