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:
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '健康测量',
|
||||
});
|
||||
398
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal file
398
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
110
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal file
110
apps/miniprogram/src/pages/pkg-health/veepoo-measure/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user