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

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

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

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

View File

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

View File

@@ -0,0 +1,398 @@
// Veepoo 实时测量页样式
.vm-page {
min-height: 100vh;
background: var(--tk-bg-primary);
}
// ── 连接中 ──
.vm-connect {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 0 32px;
&__anim {
position: relative;
width: 120px;
height: 120px;
margin-bottom: 24px;
}
&__ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid var(--tk-brand, #3B82F6);
animation: vm-pulse-ring 2s ease-out infinite;
}
&__center {
position: absolute;
inset: 20px;
border-radius: 50%;
background: var(--tk-brand, #3B82F6);
display: flex;
align-items: center;
justify-content: center;
}
&__bt {
color: #fff;
font-size: 20px;
font-weight: 700;
}
&__title {
font-size: 18px;
font-weight: 600;
color: var(--tk-text-primary);
margin-bottom: 8px;
}
&__hint {
font-size: 14px;
color: var(--tk-text-tertiary);
margin-bottom: 24px;
}
&__error {
margin-top: 16px;
text-align: center;
}
&__error-text {
font-size: 14px;
color: var(--tk-color-danger, #EF4444);
display: block;
margin-bottom: 16px;
}
&__error-btn {
width: 200px;
margin: 0 auto;
}
}
@keyframes vm-pulse-ring {
0% { transform: scale(1); opacity: 1; }
100% { transform: scale(1.4); opacity: 0; }
}
// ── 就绪/测量中 ──
.vm-body {
padding: 16px;
}
// ── 设备状态栏 ──
.vm-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--tk-bg-secondary);
border-radius: 12px;
margin-bottom: 16px;
&__device {
display: flex;
align-items: center;
gap: 8px;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--tk-color-success, #10B981);
&--on { background: var(--tk-color-success, #10B981); }
}
&__name {
font-size: 14px;
font-weight: 500;
color: var(--tk-text-primary);
}
&__battery {
font-size: 12px;
color: var(--tk-text-tertiary);
}
&__disconnect {
font-size: 13px;
color: var(--tk-text-tertiary);
padding: 4px 8px;
}
}
// ── 指标选择器 ──
.vm-selector {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
margin-bottom: 24px;
&__item {
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 4px;
border-radius: 12px;
border: 2px solid transparent;
background: var(--tk-bg-secondary);
position: relative;
transition: all 0.2s;
&--active {
border-color: var(--tk-brand, #3B82F6);
background: var(--tk-bg-tertiary);
}
&--measuring {
opacity: 0.7;
}
&--done {
border-color: var(--tk-color-success, #10B981);
}
}
&__icon {
font-size: 22px;
margin-bottom: 4px;
}
&__label {
font-size: 12px;
color: var(--tk-text-secondary);
}
&__check {
position: absolute;
top: 4px;
right: 4px;
width: 16px;
height: 16px;
border-radius: 50%;
color: #fff;
font-size: 10px;
display: flex;
align-items: center;
justify-content: center;
}
}
// ── 仪表盘 ──
.vm-gauge {
display: flex;
justify-content: center;
padding: 16px 0;
&__ring {
position: relative;
width: 220px;
height: 220px;
&--measuring {
animation: vm-gauge-breathe 2s ease-in-out infinite;
}
}
&__svg {
width: 100%;
height: 100%;
}
&__progress {
transition: stroke-dasharray 0.3s ease-out;
}
&__center {
position: absolute;
inset: 20px;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
}
&__idle {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
&__icon {
font-size: 40px;
}
&__hint {
font-size: 13px;
color: var(--tk-text-tertiary);
}
&__measuring, &__success {
display: flex;
flex-direction: column;
align-items: center;
}
&__value {
font-size: 48px;
font-weight: 700;
line-height: 1;
}
&__unit {
font-size: 14px;
color: var(--tk-text-secondary);
margin-top: 4px;
}
&__loading {
font-size: 16px;
color: var(--tk-text-secondary);
}
&__error {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
&__err-icon {
font-size: 36px;
color: var(--tk-color-danger, #EF4444);
font-weight: 700;
}
&__err-text {
font-size: 13px;
color: var(--tk-text-secondary);
text-align: center;
}
}
@keyframes vm-gauge-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.03); }
}
// ── 健康评估 ──
.vm-assessment {
text-align: center;
padding: 12px;
border-radius: 8px;
margin: 0 16px 16px;
&--normal {
background: #ECFDF5;
color: #059669;
}
&--warning {
background: #FFFBEB;
color: #D97706;
}
&--danger {
background: #FEF2F2;
color: #DC2626;
}
&__text {
font-size: 14px;
font-weight: 500;
}
}
// ── 免责声明 ──
.vm-disclaimer {
text-align: center;
padding: 8px 16px;
margin-bottom: 16px;
&__text {
font-size: 11px;
color: var(--tk-text-quaternary);
line-height: 1.5;
}
}
// ── 操作按钮 ──
.vm-actions {
padding: 0 16px;
&__row {
display: flex;
gap: 12px;
}
}
// ── 测量错误 ──
.vm-measure-error {
text-align: center;
padding: 8px 16px;
margin-top: 12px;
&__text {
font-size: 13px;
color: var(--tk-color-danger, #EF4444);
}
}
// ── 长者模式 ──
.elder-mode {
.vm-selector {
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.vm-selector__item {
padding: 16px 8px;
}
.vm-selector__icon {
font-size: 28px;
}
.vm-selector__label {
font-size: 16px;
}
.vm-gauge {
&__ring {
width: 260px;
height: 260px;
}
&__value {
font-size: 64px;
}
&__unit {
font-size: 18px;
}
&__hint {
font-size: 16px;
}
}
.vm-header__name {
font-size: 16px;
}
.vm-disclaimer__text {
font-size: 14px;
}
.vm-assessment__text {
font-size: 16px;
}
}

View File

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