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

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

View File

@@ -1,10 +1,13 @@
// Veepoo 实时测量页样式
// Veepoo 测量结果 + 上传页样式
// 设计原型: docs/design/veepoo-measure-prototype.html
@import '../../../styles/variables.scss';
.vm-page {
min-height: 100vh;
background: var(--tk-bg-primary);
background: var(--tk-bg-primary, $bg);
}
// ── 连接中 ──
// ── 连接中(等待跳转态) ──
.vm-connect {
display: flex;
flex-direction: column;
@@ -24,7 +27,7 @@
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--tk-brand, #3B82F6);
border: 3px solid $pri;
animation: vm-pulse-ring 2s ease-out infinite;
}
@@ -32,7 +35,7 @@
position: absolute;
inset: 20px;
border-radius: 50%;
background: var(--tk-brand, #3B82F6);
background: $pri;
display: flex;
align-items: center;
justify-content: center;
@@ -45,33 +48,16 @@
}
&__title {
font-size: 18px;
font-weight: 600;
color: var(--tk-text-primary);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
margin-bottom: 8px;
}
&__hint {
font-size: 14px;
color: var(--tk-text-tertiary);
margin-bottom: 24px;
}
&__error {
margin-top: 16px;
text-align: center;
}
&__error-text {
font-size: 14px;
color: var(--tk-color-danger, #EF4444);
display: block;
margin-bottom: 16px;
}
&__error-btn {
width: 200px;
margin: 0 auto;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
}
@@ -80,319 +66,191 @@
100% { transform: scale(1.4); opacity: 0; }
}
// ── 就绪/测量中 ──
.vm-body {
padding: 16px;
}
// ── 上传页面 ──
.vm-upload {
min-height: 100vh;
padding-bottom: 40px;
// ── 设备状态栏 ──
.vm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--tk-bg-secondary);
border-radius: 12px;
margin-bottom: 16px;
&__device {
display: flex;
align-items: center;
gap: 8px;
&__header {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-md, 16px);
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tk-color-success, #10B981);
&--on { background: var(--tk-color-success, #10B981); }
&__title {
display: block;
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-h2, 22px);
font-weight: 700;
color: $tx;
line-height: 1.3;
}
&__name {
font-size: 14px;
font-weight: 500;
color: var(--tk-text-primary);
}
&__battery {
font-size: 12px;
color: var(--tk-text-tertiary);
}
&__disconnect {
font-size: 13px;
color: var(--tk-text-tertiary);
padding: 4px 8px;
&__subtitle {
display: block;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
margin-top: 4px;
}
}
// ── 指标选择器 ──
.vm-selector {
// ── 结果卡片网格 ──
.vm-results-grid {
padding: 0 var(--tk-page-padding, 20px);
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 24px;
grid-template-columns: repeat(2, 1fr);
gap: var(--tk-gap-sm, 12px);
}
&__item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 4px;
border-radius: 12px;
border: 2px solid transparent;
background: var(--tk-bg-secondary);
position: relative;
transition: all 0.2s;
.vm-result-card {
background: $card;
border-radius: var(--tk-card-radius, 16px);
padding: var(--tk-gap-md, 16px);
box-shadow: $shadow-sm;
position: relative;
overflow: hidden;
&--active {
border-color: var(--tk-brand, #3B82F6);
background: var(--tk-bg-tertiary);
}
&--measuring {
opacity: 0.7;
}
&--done {
border-color: var(--tk-color-success, #10B981);
}
&--full {
grid-column: 1 / -1;
}
&__icon {
font-size: 22px;
margin-bottom: 4px;
&--empty {
opacity: 0.5;
}
&__badge {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
border-radius: 0 4px 4px 0;
}
&__label {
font-size: 12px;
color: var(--tk-text-secondary);
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx2;
margin-bottom: 8px;
padding-left: 8px;
}
&__check {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
color: #fff;
font-size: 10px;
&__row {
display: flex;
align-items: center;
justify-content: center;
}
}
// ── 仪表盘 ──
.vm-gauge {
display: flex;
justify-content: center;
padding: 16px 0;
&__ring {
position: relative;
width: 220px;
height: 220px;
&--measuring {
animation: vm-gauge-breathe 2s ease-in-out infinite;
}
}
&__svg {
width: 100%;
height: 100%;
}
&__progress {
transition: stroke-dasharray 0.3s ease-out;
}
&__center {
position: absolute;
inset: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
&__idle {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
&__icon {
font-size: 40px;
}
&__hint {
font-size: 13px;
color: var(--tk-text-tertiary);
}
&__measuring, &__success {
display: flex;
flex-direction: column;
align-items: center;
align-items: baseline;
gap: 4px;
padding-left: 8px;
}
&__value {
font-size: 48px;
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-num-lg, 34px);
font-weight: 700;
color: $tx;
line-height: 1;
}
&__unit {
font-size: 14px;
color: var(--tk-text-secondary);
margin-top: 4px;
font-size: var(--tk-font-cap, 13px);
color: $tx3;
}
&__loading {
font-size: 16px;
color: var(--tk-text-secondary);
}
&__error {
display: flex;
flex-direction: column;
&__tag {
display: inline-flex;
align-items: center;
gap: 8px;
}
&__err-icon {
font-size: 36px;
color: var(--tk-color-danger, #EF4444);
font-weight: 700;
}
&__err-text {
font-size: 13px;
color: var(--tk-text-secondary);
text-align: center;
}
}
@keyframes vm-gauge-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
// ── 健康评估 ──
.vm-assessment {
text-align: center;
padding: 12px;
border-radius: 8px;
margin: 0 16px 16px;
&--normal {
background: #ECFDF5;
color: #059669;
}
&--warning {
background: #FFFBEB;
color: #D97706;
}
&--danger {
background: #FEF2F2;
color: #DC2626;
}
&__text {
font-size: 14px;
gap: 3px;
margin-top: 8px;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
font-size: var(--tk-font-micro, 11px);
font-weight: 500;
&--normal {
background: $acc-l;
color: $acc;
}
&--warning {
background: $wrn-l;
color: $wrn;
}
&--danger {
background: $dan-l;
color: $dan;
}
}
&__placeholder {
padding-left: 8px;
font-size: var(--tk-font-body-sm, 14px);
color: $tx3;
}
&--sleep {
padding-bottom: 12px;
}
}
// ── 免责声明 ──
.vm-disclaimer {
text-align: center;
padding: 8px 16px;
margin-bottom: 16px;
// ── 睡眠数据行 ──
.vm-sleep-row {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 8px;
margin-left: 8px;
&__text {
font-size: 11px;
color: var(--tk-text-quaternary);
line-height: 1.5;
&__day {
font-size: var(--tk-font-body-sm, 14px);
color: $tx2;
min-width: 40px;
}
&__time {
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--tk-font-body, 16px);
font-weight: 600;
color: $tx;
}
&__quality {
font-size: 12px;
color: $wrn;
margin-left: auto;
}
}
// ── 操作按钮 ──
.vm-actions {
padding: 0 16px;
// ── 底部上传播区 ──
.vm-upload-footer {
padding: var(--tk-gap-lg, 24px) var(--tk-page-padding, 20px) var(--tk-gap-xl, 32px);
&__row {
display: flex;
gap: 12px;
&__hint {
display: block;
font-size: var(--tk-font-cap, 13px);
color: $tx3;
text-align: center;
margin-bottom: var(--tk-gap-sm, 12px);
}
}
// ── 测量错误 ──
.vm-measure-error {
text-align: center;
padding: 8px 16px;
margin-top: 12px;
&__btn {
width: 100%;
}
&__text {
font-size: 13px;
color: var(--tk-color-danger, #EF4444);
&__time {
display: block;
font-size: var(--tk-font-micro, 11px);
color: $tx3;
text-align: center;
margin-top: var(--tk-gap-sm, 12px);
}
}
// ── 长者模式 ──
.elder-mode {
.vm-selector {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
.vm-results-grid {
grid-template-columns: 1fr;
}
.vm-selector__item {
padding: 16px 8px;
}
.vm-selector__icon {
font-size: 28px;
}
.vm-selector__label {
font-size: 16px;
}
.vm-gauge {
&__ring {
width: 260px;
height: 260px;
}
&__value {
font-size: 64px;
}
&__unit {
font-size: 18px;
}
&__hint {
font-size: 16px;
}
}
.vm-header__name {
font-size: 16px;
}
.vm-disclaimer__text {
font-size: 14px;
}
.vm-assessment__text {
font-size: 16px;
.vm-result-card__value {
font-size: var(--tk-font-num-lg, 40px);
}
}

View File

@@ -1,6 +1,6 @@
import React, { useRef } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useDidShow } from '@tarojs/taro';
import Taro, { useDidShow, useRouter } from '@tarojs/taro';
import { useAuthStore } from '@/stores/auth';
import { uploadReadings } from '@/services/device-sync';
import type { NormalizedReading } from '@/services/ble/types';
@@ -16,17 +16,89 @@ interface NativeMeasureResult {
measuredAt: number;
}
/** 原生页面返回的睡眠数据格式 */
interface NativeSleepResult {
day: number;
deepSleepMinutes: number;
lightSleepMinutes: number;
totalSleepMinutes: number;
qualityScore: number;
fallAsleepTime: string;
exitSleepTime: string;
}
/** 指标配置 */
const METRIC_CONFIG = [
{ type: 'heart_rate', label: '心率', unit: 'bpm', color: '#EF4444', icon: '♥' },
{ type: 'blood_oxygen', label: '血氧', unit: '%', color: '#3B82F6', icon: 'O₂' },
{ type: 'blood_pressure', label: '血压', unit: 'mmHg', color: '#8B5CF6', icon: '↕' },
{ type: 'temperature', label: '体温', unit: '°C', color: '#F59E0B', icon: 'T' },
{ type: 'pressure', label: '压力', unit: '', color: '#6366F1', icon: '~' },
] as const;
/** 健康评估 */
function assessHealth(type: string, values: Record<string, number>): { level: 'normal' | 'warning' | 'danger'; text: string } {
switch (type) {
case 'heart_rate': {
const v = values.heart_rate ?? 0;
if (v >= 60 && v <= 100) return { level: 'normal', text: '心率正常' };
if (v < 50 || v > 120) return { level: 'danger', text: '心率异常' };
return { level: 'warning', text: '心率偏离正常范围' };
}
case 'blood_oxygen': {
const v = values.blood_oxygen ?? 0;
if (v >= 95) return { level: 'normal', text: '血氧正常' };
if (v >= 90) return { level: 'warning', text: '血氧偏低' };
return { level: 'danger', text: '血氧过低' };
}
case 'blood_pressure': {
const sys = values.systolic ?? 0;
const dia = values.diastolic ?? 0;
if (sys >= 90 && sys <= 140 && dia >= 60 && dia <= 90) return { level: 'normal', text: '血压正常' };
if (sys > 160 || dia > 100) return { level: 'danger', text: '血压过高' };
return { level: 'warning', text: '血压偏高' };
}
case 'temperature': {
const v = values.temperature ?? 0;
if (v >= 36.0 && v <= 37.3) return { level: 'normal', text: '体温正常' };
if (v > 38.0) return { level: 'danger', text: '发热' };
return { level: 'warning', text: '体温偏离正常' };
}
case 'pressure': {
const v = values.pressure ?? 0;
if (v >= 1 && v <= 40) return { level: 'normal', text: '压力正常' };
if (v > 60) return { level: 'danger', text: '压力过高' };
return { level: 'warning', text: '压力偏高' };
}
default:
return { level: 'normal', text: '' };
}
}
/** 格式化显示值 */
function formatValue(type: string, values: Record<string, number>): string {
if (type === 'blood_pressure') {
return `${values.systolic ?? '--'}/${values.diastolic ?? '--'}`;
}
const v = Object.values(values)[0];
return v !== undefined ? String(v) : '--';
}
export default function VeepooMeasure() {
const modeClass = useElderClass();
const router = useRouter();
const patient = useAuthStore((s) => s.currentPatient);
const navigatedRef = useRef(false);
const [results, setResults] = React.useState<Record<string, NativeMeasureResult>>({});
const [uploadStatus, setUploadStatus] = React.useState<string>('');
const [sleepData, setSleepData] = React.useState<NativeSleepResult[]>([]);
const [uploadStatus, setUploadStatus] = React.useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
// 从 URL 或 store 获取 patientId
const patientId = patient?.id || router.params.patientId || '';
// C3 修复:用 ref 防重入,避免 React Strict Mode 双触发
if (!navigatedRef.current) {
navigatedRef.current = true;
const patientId = patient?.id || '';
// 延迟到下一个微任务,确保页面渲染完成后再跳转
setTimeout(() => {
Taro.navigateTo({
@@ -43,7 +115,7 @@ export default function VeepooMeasure() {
}, 50);
}
// 页面恢复时读取原生页面返回的测量结果
// 页面恢复时读取原生页面返回的测量结果 + 睡眠数据
useDidShow(() => {
try {
const raw = Taro.getStorageSync('hms:veepoo_measure_results');
@@ -53,58 +125,182 @@ export default function VeepooMeasure() {
Taro.removeStorageSync('hms:veepoo_measure_results');
}
} catch { /* ignore */ }
try {
const rawSleep = Taro.getStorageSync('hms:veepoo_sleep_results');
if (rawSleep) {
const parsedSleep = JSON.parse(rawSleep) as NativeSleepResult[];
if (parsedSleep.length > 0) {
setSleepData(parsedSleep);
}
Taro.removeStorageSync('hms:veepoo_sleep_results');
}
} catch { /* ignore */ }
});
const handleUpload = async () => {
if (!patient) return;
// 修复:添加明确的错误提示,不再静默退出
if (!patientId) {
console.warn('[veepoo-measure] 上传失败:未获取到患者 ID');
Taro.showToast({ title: '请先绑定患者档案', icon: 'none' });
return;
}
const allResults = Object.values(results);
if (allResults.length === 0) return;
const hasMeasureData = allResults.length > 0;
const hasSleep = sleepData.length > 0;
setUploadStatus('上传中...');
if (!hasMeasureData && !hasSleep) {
console.warn('[veepoo-measure] 上传失败:无数据');
Taro.showToast({ title: '暂无测量数据', icon: 'none' });
return;
}
setUploadStatus('uploading');
try {
// C2 修复:使用 uploadReadings,类型与 NormalizedReading 对齐
const readings: NormalizedReading[] = allResults.map((r) => ({
device_type: r.type as NormalizedReading['device_type'],
values: r.values,
measured_at: new Date(r.measuredAt).toISOString(),
}));
await uploadReadings(patient.id, 'veepoo_m2', 'Veepoo M2', readings);
setUploadStatus('上传成功');
const allReadings: NormalizedReading[] = [];
// 测量结果
if (hasMeasureData) {
console.log('[veepoo-measure] 上传测量数据', allResults.length, '项');
allReadings.push(...allResults.map((r) => ({
device_type: r.type as NormalizedReading['device_type'],
values: r.values,
measured_at: new Date(r.measuredAt).toISOString(),
})));
}
// 睡眠数据
if (hasSleep) {
const now = new Date();
console.log('[veepoo-measure] 上传睡眠数据', sleepData.length, '天');
allReadings.push(...sleepData.map((s) => {
const baseDate = new Date(now.getTime() - s.day * 86400000);
return {
device_type: 'sleep' as const,
values: {
deep_sleep_minutes: s.deepSleepMinutes,
light_sleep_minutes: s.lightSleepMinutes,
total_sleep_minutes: s.totalSleepMinutes,
quality_score: s.qualityScore,
},
measured_at: baseDate.toISOString(),
};
}));
}
await uploadReadings(patientId, 'veepoo_m2', 'Veepoo M2', allReadings);
setUploadStatus('success');
Taro.showToast({ title: '数据已上传', icon: 'success' });
} catch {
setUploadStatus('上传失败');
Taro.showToast({ title: '上传失败', icon: 'none' });
} catch (err) {
console.error('[veepoo-measure] 上传失败:', err);
setUploadStatus('error');
Taro.showToast({ title: '上传失败,请重试', icon: 'none' });
}
};
const hasResults = Object.keys(results).length > 0;
const measuredCount = Object.keys(results).length;
const measuredAt = hasResults
? new Date(Object.values(results)[0].measuredAt).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
: '';
return (
<PageShell padding="none" className={`vm-page ${modeClass}`}>
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
{hasResults ? (
<View className="vm-results">
{Object.entries(results).map(([type, r]) => (
<View key={type} className="vm-results__item">
<Text className="vm-results__type">{type}</Text>
<Text className="vm-results__value">{JSON.stringify(r.values)}</Text>
</View>
))}
<Text className="vm-connect__hint">{uploadStatus}</Text>
<View className="vm-connect__error-btn">
<PrimaryButton onClick={handleUpload}></PrimaryButton>
</View>
{hasResults ? (
<View className="vm-upload">
{/* 页面标题 */}
<View className="vm-upload__header">
<Text className="vm-upload__title"></Text>
<Text className="vm-upload__subtitle">Veepoo M2 · </Text>
</View>
) : (
{/* 结果卡片网格 */}
<View className="vm-results-grid">
{METRIC_CONFIG.map((metric) => {
const result = results[metric.type];
if (result) {
const assessment = assessHealth(metric.type, result.values);
return (
<View
key={metric.type}
className={`vm-result-card ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<View className="vm-result-card__row">
<Text className="vm-result-card__value">{formatValue(metric.type, result.values)}</Text>
<Text className="vm-result-card__unit">{metric.unit}</Text>
</View>
<View className={`vm-result-card__tag vm-result-card__tag--${assessment.level}`}>
<Text> {assessment.text}</Text>
</View>
</View>
);
}
// 未测量占位
return (
<View
key={metric.type}
className={`vm-result-card vm-result-card--empty ${metric.type === 'blood_pressure' ? 'vm-result-card--full' : ''}`}
>
<View className="vm-result-card__badge" style={{ background: metric.color, opacity: 0.3 }} />
<Text className="vm-result-card__label">{metric.label}</Text>
<Text className="vm-result-card__placeholder"></Text>
</View>
);
})}
{/* 睡眠数据卡片 */}
{sleepData.length > 0 && (
<View className="vm-result-card vm-result-card--full vm-result-card--sleep">
<View className="vm-result-card__badge" style={{ background: '#5B7A5E' }} />
<Text className="vm-result-card__label">{sleepData.length} </Text>
{sleepData.map((sleep, idx) => {
const hours = Math.floor(sleep.totalSleepMinutes / 60);
const mins = sleep.totalSleepMinutes % 60;
const dayLabel = sleep.day === 0 ? '昨晚' : sleep.day === 1 ? '前晚' : '大前晚';
return (
<View key={idx} className="vm-sleep-row">
<Text className="vm-sleep-row__day">{dayLabel}</Text>
<Text className="vm-sleep-row__time">{hours}h{mins > 0 ? ` ${mins}min` : ''}</Text>
<View className="vm-sleep-row__quality">
{'★'.repeat(Math.min(sleep.qualityScore, 5))}{'☆'.repeat(Math.max(5 - sleep.qualityScore, 0))}
</View>
</View>
);
})}
<View className="vm-result-card__tag vm-result-card__tag--normal">
<Text> </Text>
</View>
</View>
)}
</View>
{/* 底部上传播区 */}
<View className="vm-upload-footer">
<Text className="vm-upload-footer__hint"></Text>
<View className="vm-upload-footer__btn">
<PrimaryButton onClick={handleUpload} disabled={uploadStatus === 'uploading'}>
{uploadStatus === 'uploading'
? '上传中...'
: uploadStatus === 'success'
? '✓ 已上传'
: `上传数据(${measuredCount} 项测量${sleepData.length > 0 ? ' + ' + sleepData.length + ' 天睡眠' : ''}`}
</PrimaryButton>
</View>
{measuredAt && <Text className="vm-upload-footer__time">{measuredAt}</Text>}
</View>
</View>
) : (
<View className="vm-connect">
<View className="vm-connect__anim">
<View className="vm-connect__ring" />
<View className="vm-connect__center"><Text className="vm-connect__bt">BT</Text></View>
</View>
<Text className="vm-connect__title">M2 </Text>
<Text className="vm-connect__hint">...</Text>
)}
</View>
</View>
)}
</PageShell>
);
}