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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user