refactor(mp): E3-2 大文件拆分 + U3-2 微交互统一
E3-2 大文件拆分(3 文件 → 6 文件): - daily-monitoring 449L → useDailyMonitoring.ts hook(238L) + 页面(255L) - request.ts 376L → cache.ts(75L) + limiter.ts(32L) + 主文件(278L) - BLEManager.ts 363L → BLEConnection.ts(212L) + 主文件(228L) U3-2 微交互统一: - 新增 haptic.ts 工具(light/medium/heavy 三级触觉反馈) - PrimaryButton 点击触发 hapticLight() - tokens.scss 新增 5 个动画时序 token(duration/easing) - mixins.scss 新增 fade-in() mixin(支持 fast/normal/slow 三档)
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
|
import { hapticLight } from '@/utils/haptic';
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
interface PrimaryButtonProps {
|
interface PrimaryButtonProps {
|
||||||
@@ -27,8 +28,14 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
|||||||
className,
|
className,
|
||||||
].filter(Boolean).join(' ');
|
].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (disabled || loading) return;
|
||||||
|
hapticLight();
|
||||||
|
onClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className={cls} role="button" aria-disabled={disabled} aria-busy={loading} onClick={!disabled && !loading ? onClick : undefined}>
|
<View className={cls} role="button" aria-disabled={disabled} aria-busy={loading} onClick={handleClick}>
|
||||||
{loading && <View className='primary-btn__spinner' />}
|
{loading && <View className='primary-btn__spinner' />}
|
||||||
<Text className='primary-btn__text'>{children}</Text>
|
<Text className='primary-btn__text'>{children}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,223 +1,29 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { View, Text, Input, Picker } from '@tarojs/components';
|
import { View, Text, Input, Picker } from '@tarojs/components';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
|
||||||
import { validateNum } from '@/utils/validate';
|
|
||||||
import { createDailyMonitoring } from '@/services/health';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
import { useHealthStore } from '@/stores/health';
|
|
||||||
import { usePointsStore } from '@/stores/points';
|
|
||||||
import { clearRequestCache } from '@/services/request';
|
|
||||||
import { trackEvent } from '@/services/analytics';
|
|
||||||
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
|
||||||
import { useElderClass } from '@/hooks/useElderClass';
|
import { useElderClass } from '@/hooks/useElderClass';
|
||||||
import PageShell from '@/components/ui/PageShell';
|
import PageShell from '@/components/ui/PageShell';
|
||||||
import ContentCard from '@/components/ui/ContentCard';
|
import ContentCard from '@/components/ui/ContentCard';
|
||||||
import {
|
import { useDailyMonitoring } from './useDailyMonitoring';
|
||||||
BP_RANGE, WEIGHT_RANGE, SUGAR_RANGE, VOLUME_RANGE,
|
|
||||||
checkAbnormal, formatDate, FIELD_LABELS,
|
|
||||||
type SectionKey,
|
|
||||||
} from './constants';
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
|
|
||||||
export default function DailyMonitoring() {
|
export default function DailyMonitoring() {
|
||||||
const modeClass = useElderClass();
|
const modeClass = useElderClass();
|
||||||
const currentPatient = useAuthStore((s) => s.currentPatient);
|
const {
|
||||||
|
dateIdx, setDateIdx, dateList, recordDate, isToday,
|
||||||
const today = formatDate(new Date());
|
morningSystolic, setMorningSystolic,
|
||||||
const [dateIdx, setDateIdx] = useState(0);
|
morningDiastolic, setMorningDiastolic,
|
||||||
const [dateList] = useState(() => {
|
eveningSystolic, setEveningSystolic,
|
||||||
const list: string[] = [];
|
eveningDiastolic, setEveningDiastolic,
|
||||||
for (let i = 0; i < 30; i++) {
|
weight, setWeight,
|
||||||
const d = new Date();
|
bloodSugar, setBloodSugar,
|
||||||
d.setDate(d.getDate() - i);
|
fluidIntake, setFluidIntake,
|
||||||
list.push(formatDate(d));
|
urineOutput, setUrineOutput,
|
||||||
}
|
notes, setNotes,
|
||||||
return list;
|
submitting, collapsed, toggleSection,
|
||||||
});
|
handleSubmit, resetForm,
|
||||||
const recordDate = dateList[dateIdx];
|
morningSysAbnormal, morningDiaAbnormal,
|
||||||
|
eveningSysAbnormal, eveningDiaAbnormal,
|
||||||
const [morningSystolic, setMorningSystolic] = useState('');
|
bloodSugarAbnormal,
|
||||||
const [morningDiastolic, setMorningDiastolic] = useState('');
|
} = useDailyMonitoring();
|
||||||
const [eveningSystolic, setEveningSystolic] = useState('');
|
|
||||||
const [eveningDiastolic, setEveningDiastolic] = useState('');
|
|
||||||
const [weight, setWeight] = useState('');
|
|
||||||
const [bloodSugar, setBloodSugar] = useState('');
|
|
||||||
const [fluidIntake, setFluidIntake] = useState('');
|
|
||||||
const [urineOutput, setUrineOutput] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const { safeSetTimeout } = useSafeTimeout();
|
|
||||||
|
|
||||||
// ── Collapsible sections ──
|
|
||||||
const [collapsed, setCollapsed] = useState<Record<SectionKey, boolean>>({
|
|
||||||
morning: false,
|
|
||||||
evening: false,
|
|
||||||
other: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleSection = (key: SectionKey) => {
|
|
||||||
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
|
|
||||||
};
|
|
||||||
|
|
||||||
useDidShow(() => {
|
|
||||||
Taro.setNavigationBarTitle({ title: '日常监测上报' });
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setMorningSystolic('');
|
|
||||||
setMorningDiastolic('');
|
|
||||||
setEveningSystolic('');
|
|
||||||
setEveningDiastolic('');
|
|
||||||
setWeight('');
|
|
||||||
setBloodSugar('');
|
|
||||||
setFluidIntake('');
|
|
||||||
setUrineOutput('');
|
|
||||||
setNotes('');
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Abnormal field gathering for submit confirmation ──
|
|
||||||
const gatherAbnormalFields = (): string[] => {
|
|
||||||
const abnormalFields: string[] = [];
|
|
||||||
|
|
||||||
const checks: Array<[string, string]> = [
|
|
||||||
['morningSystolic', morningSystolic],
|
|
||||||
['morningDiastolic', morningDiastolic],
|
|
||||||
['eveningSystolic', eveningSystolic],
|
|
||||||
['eveningDiastolic', eveningDiastolic],
|
|
||||||
['bloodSugar', bloodSugar],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [field, value] of checks) {
|
|
||||||
const result = checkAbnormal(value, field);
|
|
||||||
if (result.abnormal) {
|
|
||||||
abnormalFields.push(FIELD_LABELS[field]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return abnormalFields;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!currentPatient) {
|
|
||||||
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasData =
|
|
||||||
morningSystolic || morningDiastolic ||
|
|
||||||
eveningSystolic || eveningDiastolic ||
|
|
||||||
weight || bloodSugar || fluidIntake || urineOutput;
|
|
||||||
|
|
||||||
if (!hasData) {
|
|
||||||
Taro.showToast({ title: '请至少填写一项数据', icon: 'none' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) {
|
|
||||||
Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) {
|
|
||||||
Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseNum = (v: string) => v ? parseFloat(v) : undefined;
|
|
||||||
const fields = {
|
|
||||||
morningSystolic: parseNum(morningSystolic),
|
|
||||||
morningDiastolic: parseNum(morningDiastolic),
|
|
||||||
eveningSystolic: parseNum(eveningSystolic),
|
|
||||||
eveningDiastolic: parseNum(eveningDiastolic),
|
|
||||||
weight: parseNum(weight),
|
|
||||||
bloodSugar: parseNum(bloodSugar),
|
|
||||||
fluidIntake: parseNum(fluidIntake),
|
|
||||||
urineOutput: parseNum(urineOutput),
|
|
||||||
};
|
|
||||||
|
|
||||||
const validations: Array<[number | undefined, string, typeof BP_RANGE]> = [
|
|
||||||
[fields.morningSystolic, '晨起收缩压', BP_RANGE],
|
|
||||||
[fields.morningDiastolic, '晨起舒张压', BP_RANGE],
|
|
||||||
[fields.eveningSystolic, '晚间收缩压', BP_RANGE],
|
|
||||||
[fields.eveningDiastolic, '晚间舒张压', BP_RANGE],
|
|
||||||
[fields.weight, '体重', WEIGHT_RANGE],
|
|
||||||
[fields.bloodSugar, '血糖', SUGAR_RANGE],
|
|
||||||
[fields.fluidIntake, '饮水量', VOLUME_RANGE],
|
|
||||||
[fields.urineOutput, '尿量', VOLUME_RANGE],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [value, label, range] of validations) {
|
|
||||||
const err = validateNum(value, label, range);
|
|
||||||
if (err) {
|
|
||||||
Taro.showToast({ title: err, icon: 'none' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Pre-submit abnormal confirmation ──
|
|
||||||
const abnormalFields = gatherAbnormalFields();
|
|
||||||
if (abnormalFields.length > 0) {
|
|
||||||
const confirmed = await Taro.showModal({
|
|
||||||
title: '数值异常提醒',
|
|
||||||
content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`,
|
|
||||||
confirmText: '确认提交',
|
|
||||||
cancelText: '返回修改',
|
|
||||||
});
|
|
||||||
if (!confirmed.confirm) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
await createDailyMonitoring({
|
|
||||||
patient_id: currentPatient.id,
|
|
||||||
record_date: recordDate,
|
|
||||||
morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined,
|
|
||||||
morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined,
|
|
||||||
evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined,
|
|
||||||
evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined,
|
|
||||||
weight: weight ? parseFloat(weight) : undefined,
|
|
||||||
blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined,
|
|
||||||
fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined,
|
|
||||||
urine_output: urineOutput ? parseFloat(urineOutput) : undefined,
|
|
||||||
notes: notes || undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
trackEvent('daily_monitoring_submit', { date: recordDate });
|
|
||||||
useHealthStore.getState().clearCache();
|
|
||||||
clearRequestCache('/health/');
|
|
||||||
usePointsStore.getState().invalidate();
|
|
||||||
Taro.showToast({ title: '上报成功', icon: 'success' });
|
|
||||||
|
|
||||||
safeSetTimeout(() => {
|
|
||||||
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
|
|
||||||
}, 1600);
|
|
||||||
|
|
||||||
safeSetTimeout(() => {
|
|
||||||
Taro.navigateBack();
|
|
||||||
}, 3200);
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const msg = e instanceof Error ? e.message : '上报失败';
|
|
||||||
if (msg.includes('已有记录') || msg.includes('already exists')) {
|
|
||||||
Taro.showModal({
|
|
||||||
title: '提示',
|
|
||||||
content: '该日期已有监测记录,请选择其他日期',
|
|
||||||
showCancel: false,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
Taro.showToast({ title: msg, icon: 'none' });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isToday = recordDate === today;
|
|
||||||
|
|
||||||
// ── Abnormal state helpers for rendering ──
|
|
||||||
const morningSysAbnormal = checkAbnormal(morningSystolic, 'systolic');
|
|
||||||
const morningDiaAbnormal = checkAbnormal(morningDiastolic, 'diastolic');
|
|
||||||
const eveningSysAbnormal = checkAbnormal(eveningSystolic, 'systolic');
|
|
||||||
const eveningDiaAbnormal = checkAbnormal(eveningDiastolic, 'diastolic');
|
|
||||||
const bloodSugarAbnormal = checkAbnormal(bloodSugar, 'bloodSugar');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell padding="none" safeBottom className={modeClass}>
|
<PageShell padding="none" safeBottom className={modeClass}>
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
|
import { validateNum } from '@/utils/validate';
|
||||||
|
import { createDailyMonitoring } from '@/services/health';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useHealthStore } from '@/stores/health';
|
||||||
|
import { usePointsStore } from '@/stores/points';
|
||||||
|
import { clearRequestCache } from '@/services/request';
|
||||||
|
import { trackEvent } from '@/services/analytics';
|
||||||
|
import { useSafeTimeout } from '@/hooks/useSafeTimeout';
|
||||||
|
import {
|
||||||
|
BP_RANGE, WEIGHT_RANGE, SUGAR_RANGE, VOLUME_RANGE,
|
||||||
|
checkAbnormal, formatDate, FIELD_LABELS,
|
||||||
|
type SectionKey,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
export function useDailyMonitoring() {
|
||||||
|
const currentPatient = useAuthStore((s) => s.currentPatient);
|
||||||
|
|
||||||
|
const today = formatDate(new Date());
|
||||||
|
const [dateIdx, setDateIdx] = useState(0);
|
||||||
|
const [dateList] = useState(() => {
|
||||||
|
const list: string[] = [];
|
||||||
|
for (let i = 0; i < 30; i++) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
list.push(formatDate(d));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
const recordDate = dateList[dateIdx];
|
||||||
|
|
||||||
|
const [morningSystolic, setMorningSystolic] = useState('');
|
||||||
|
const [morningDiastolic, setMorningDiastolic] = useState('');
|
||||||
|
const [eveningSystolic, setEveningSystolic] = useState('');
|
||||||
|
const [eveningDiastolic, setEveningDiastolic] = useState('');
|
||||||
|
const [weight, setWeight] = useState('');
|
||||||
|
const [bloodSugar, setBloodSugar] = useState('');
|
||||||
|
const [fluidIntake, setFluidIntake] = useState('');
|
||||||
|
const [urineOutput, setUrineOutput] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const { safeSetTimeout } = useSafeTimeout();
|
||||||
|
|
||||||
|
// ── Collapsible sections ──
|
||||||
|
const [collapsed, setCollapsed] = useState<Record<SectionKey, boolean>>({
|
||||||
|
morning: false,
|
||||||
|
evening: false,
|
||||||
|
other: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleSection = (key: SectionKey) => {
|
||||||
|
setCollapsed(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
Taro.setNavigationBarTitle({ title: '日常监测上报' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setMorningSystolic('');
|
||||||
|
setMorningDiastolic('');
|
||||||
|
setEveningSystolic('');
|
||||||
|
setEveningDiastolic('');
|
||||||
|
setWeight('');
|
||||||
|
setBloodSugar('');
|
||||||
|
setFluidIntake('');
|
||||||
|
setUrineOutput('');
|
||||||
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Abnormal field gathering for submit confirmation ──
|
||||||
|
const gatherAbnormalFields = (): string[] => {
|
||||||
|
const abnormalFields: string[] = [];
|
||||||
|
|
||||||
|
const checks: Array<[string, string]> = [
|
||||||
|
['morningSystolic', morningSystolic],
|
||||||
|
['morningDiastolic', morningDiastolic],
|
||||||
|
['eveningSystolic', eveningSystolic],
|
||||||
|
['eveningDiastolic', eveningDiastolic],
|
||||||
|
['bloodSugar', bloodSugar],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [field, value] of checks) {
|
||||||
|
const result = checkAbnormal(value, field);
|
||||||
|
if (result.abnormal) {
|
||||||
|
abnormalFields.push(FIELD_LABELS[field]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return abnormalFields;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!currentPatient) {
|
||||||
|
Taro.showToast({ title: '请先选择就诊人', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasData =
|
||||||
|
morningSystolic || morningDiastolic ||
|
||||||
|
eveningSystolic || eveningDiastolic ||
|
||||||
|
weight || bloodSugar || fluidIntake || urineOutput;
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
Taro.showToast({ title: '请至少填写一项数据', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((morningSystolic && !morningDiastolic) || (!morningSystolic && morningDiastolic)) {
|
||||||
|
Taro.showToast({ title: '晨起血压请同时填写收缩压和舒张压', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((eveningSystolic && !eveningDiastolic) || (!eveningSystolic && eveningDiastolic)) {
|
||||||
|
Taro.showToast({ title: '晚间血压请同时填写收缩压和舒张压', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNum = (v: string) => v ? parseFloat(v) : undefined;
|
||||||
|
const fields = {
|
||||||
|
morningSystolic: parseNum(morningSystolic),
|
||||||
|
morningDiastolic: parseNum(morningDiastolic),
|
||||||
|
eveningSystolic: parseNum(eveningSystolic),
|
||||||
|
eveningDiastolic: parseNum(eveningDiastolic),
|
||||||
|
weight: parseNum(weight),
|
||||||
|
bloodSugar: parseNum(bloodSugar),
|
||||||
|
fluidIntake: parseNum(fluidIntake),
|
||||||
|
urineOutput: parseNum(urineOutput),
|
||||||
|
};
|
||||||
|
|
||||||
|
const validations: Array<[number | undefined, string, typeof BP_RANGE]> = [
|
||||||
|
[fields.morningSystolic, '晨起收缩压', BP_RANGE],
|
||||||
|
[fields.morningDiastolic, '晨起舒张压', BP_RANGE],
|
||||||
|
[fields.eveningSystolic, '晚间收缩压', BP_RANGE],
|
||||||
|
[fields.eveningDiastolic, '晚间舒张压', BP_RANGE],
|
||||||
|
[fields.weight, '体重', WEIGHT_RANGE],
|
||||||
|
[fields.bloodSugar, '血糖', SUGAR_RANGE],
|
||||||
|
[fields.fluidIntake, '饮水量', VOLUME_RANGE],
|
||||||
|
[fields.urineOutput, '尿量', VOLUME_RANGE],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [value, label, range] of validations) {
|
||||||
|
const err = validateNum(value, label, range);
|
||||||
|
if (err) {
|
||||||
|
Taro.showToast({ title: err, icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pre-submit abnormal confirmation ──
|
||||||
|
const abnormalFields = gatherAbnormalFields();
|
||||||
|
if (abnormalFields.length > 0) {
|
||||||
|
const confirmed = await Taro.showModal({
|
||||||
|
title: '数值异常提醒',
|
||||||
|
content: `以下指标超出正常范围:${abnormalFields.join('、')}。确认提交?`,
|
||||||
|
confirmText: '确认提交',
|
||||||
|
cancelText: '返回修改',
|
||||||
|
});
|
||||||
|
if (!confirmed.confirm) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createDailyMonitoring({
|
||||||
|
patient_id: currentPatient.id,
|
||||||
|
record_date: recordDate,
|
||||||
|
morning_bp_systolic: morningSystolic ? parseFloat(morningSystolic) : undefined,
|
||||||
|
morning_bp_diastolic: morningDiastolic ? parseFloat(morningDiastolic) : undefined,
|
||||||
|
evening_bp_systolic: eveningSystolic ? parseFloat(eveningSystolic) : undefined,
|
||||||
|
evening_bp_diastolic: eveningDiastolic ? parseFloat(eveningDiastolic) : undefined,
|
||||||
|
weight: weight ? parseFloat(weight) : undefined,
|
||||||
|
blood_sugar: bloodSugar ? parseFloat(bloodSugar) : undefined,
|
||||||
|
fluid_intake: fluidIntake ? parseFloat(fluidIntake) : undefined,
|
||||||
|
urine_output: urineOutput ? parseFloat(urineOutput) : undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
trackEvent('daily_monitoring_submit', { date: recordDate });
|
||||||
|
useHealthStore.getState().clearCache();
|
||||||
|
clearRequestCache('/health/');
|
||||||
|
usePointsStore.getState().invalidate();
|
||||||
|
Taro.showToast({ title: '上报成功', icon: 'success' });
|
||||||
|
|
||||||
|
safeSetTimeout(() => {
|
||||||
|
Taro.showToast({ title: '+10 健康积分', icon: 'none', duration: 1500 });
|
||||||
|
}, 1600);
|
||||||
|
|
||||||
|
safeSetTimeout(() => {
|
||||||
|
Taro.navigateBack();
|
||||||
|
}, 3200);
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const msg = e instanceof Error ? e.message : '上报失败';
|
||||||
|
if (msg.includes('已有记录') || msg.includes('already exists')) {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '该日期已有监测记录,请选择其他日期',
|
||||||
|
showCancel: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Taro.showToast({ title: msg, icon: 'none' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = recordDate === today;
|
||||||
|
|
||||||
|
// ── Abnormal state helpers for rendering ──
|
||||||
|
const morningSysAbnormal = checkAbnormal(morningSystolic, 'systolic');
|
||||||
|
const morningDiaAbnormal = checkAbnormal(morningDiastolic, 'diastolic');
|
||||||
|
const eveningSysAbnormal = checkAbnormal(eveningSystolic, 'systolic');
|
||||||
|
const eveningDiaAbnormal = checkAbnormal(eveningDiastolic, 'diastolic');
|
||||||
|
const bloodSugarAbnormal = checkAbnormal(bloodSugar, 'bloodSugar');
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Date state
|
||||||
|
dateIdx, setDateIdx, dateList, recordDate, isToday,
|
||||||
|
// Form field state
|
||||||
|
morningSystolic, setMorningSystolic,
|
||||||
|
morningDiastolic, setMorningDiastolic,
|
||||||
|
eveningSystolic, setEveningSystolic,
|
||||||
|
eveningDiastolic, setEveningDiastolic,
|
||||||
|
weight, setWeight,
|
||||||
|
bloodSugar, setBloodSugar,
|
||||||
|
fluidIntake, setFluidIntake,
|
||||||
|
urineOutput, setUrineOutput,
|
||||||
|
notes, setNotes,
|
||||||
|
// UI state
|
||||||
|
submitting, collapsed, toggleSection,
|
||||||
|
// Handlers
|
||||||
|
handleSubmit, resetForm,
|
||||||
|
// Abnormal indicators
|
||||||
|
morningSysAbnormal, morningDiaAbnormal,
|
||||||
|
eveningSysAbnormal, eveningDiaAbnormal,
|
||||||
|
bloodSugarAbnormal,
|
||||||
|
};
|
||||||
|
}
|
||||||
212
apps/miniprogram/src/services/ble/BLEConnection.ts
Normal file
212
apps/miniprogram/src/services/ble/BLEConnection.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
import type {
|
||||||
|
BLEDevice,
|
||||||
|
BLEConnection as BLEConnectionInfo,
|
||||||
|
BLEConnectionState,
|
||||||
|
NormalizedReading,
|
||||||
|
BLEConnectionChangeResult,
|
||||||
|
BLECharacteristicChangeResult,
|
||||||
|
BLEServiceItem,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** BLE 连接管理 — 封装连接/断开/服务发现/通知订阅/数据监听 */
|
||||||
|
export class BLEConnection {
|
||||||
|
private conn: BLEConnectionInfo | null = null;
|
||||||
|
private readings: NormalizedReading[] = [];
|
||||||
|
private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null;
|
||||||
|
private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null;
|
||||||
|
|
||||||
|
private onStateChange: (state: BLEConnectionState, error?: string) => void;
|
||||||
|
private onNewReadings: (readings: NormalizedReading[]) => void;
|
||||||
|
|
||||||
|
constructor(callbacks: {
|
||||||
|
onStateChange: (state: BLEConnectionState, error?: string) => void;
|
||||||
|
onNewReadings: (readings: NormalizedReading[]) => void;
|
||||||
|
}) {
|
||||||
|
this.onStateChange = callbacks.onStateChange;
|
||||||
|
this.onNewReadings = callbacks.onNewReadings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前连接信息 */
|
||||||
|
getConnection(): BLEConnectionInfo | null {
|
||||||
|
return this.conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取缓存的读数 */
|
||||||
|
getCachedReadings(): NormalizedReading[] {
|
||||||
|
return [...this.readings];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除缓存读数 */
|
||||||
|
clearReadings(): void {
|
||||||
|
this.readings = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置缓存读数(供外部 BLEManager 使用) */
|
||||||
|
setReadings(readings: NormalizedReading[]): void {
|
||||||
|
this.readings = readings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateState(state: BLEConnectionState, error?: string): void {
|
||||||
|
if (this.conn) {
|
||||||
|
this.conn = { ...this.conn, state, error };
|
||||||
|
}
|
||||||
|
this.onStateChange(state, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 连接到设备 */
|
||||||
|
async connect(device: BLEDevice, maxLiveReadings: number): Promise<void> {
|
||||||
|
if (!device.adapter) throw new Error('设备无适配器');
|
||||||
|
|
||||||
|
this.conn = {
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
state: 'connecting',
|
||||||
|
adapter: device.adapter,
|
||||||
|
};
|
||||||
|
this.updateState('connecting');
|
||||||
|
this.readings = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Taro.createBLEConnection({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 移除旧监听器,避免多次 connect 累积
|
||||||
|
if (this.connChangeHandler) {
|
||||||
|
Taro.offBLEConnectionStateChange(this.connChangeHandler);
|
||||||
|
}
|
||||||
|
if (this.charChangeHandler) {
|
||||||
|
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听断连
|
||||||
|
this.connChangeHandler = (res: BLEConnectionChangeResult) => {
|
||||||
|
if (res.deviceId === device.deviceId && !res.connected) {
|
||||||
|
this.updateState('disconnected', '设备断开连接');
|
||||||
|
this.conn = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Taro.onBLEConnectionStateChange(this.connChangeHandler);
|
||||||
|
|
||||||
|
// 发现服务
|
||||||
|
await this.discoverServices(device);
|
||||||
|
|
||||||
|
// 监听数据通知
|
||||||
|
this.charChangeHandler = (res: BLECharacteristicChangeResult) => {
|
||||||
|
if (res.deviceId !== device.deviceId) return;
|
||||||
|
const newReadings = device.adapter!.parseNotification(
|
||||||
|
res.serviceId,
|
||||||
|
res.characteristicId,
|
||||||
|
res.value,
|
||||||
|
);
|
||||||
|
if (newReadings.length > 0) {
|
||||||
|
const combined = [...this.readings, ...newReadings];
|
||||||
|
this.readings = combined.length > maxLiveReadings
|
||||||
|
? combined.slice(-maxLiveReadings)
|
||||||
|
: combined;
|
||||||
|
this.onNewReadings(newReadings);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Taro.onBLECharacteristicValueChange(this.charChangeHandler);
|
||||||
|
|
||||||
|
this.conn = { ...this.conn, state: 'connected', connectedAt: Date.now() };
|
||||||
|
this.updateState('connected');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const errMsg = (e as { errMsg?: string })?.errMsg;
|
||||||
|
const msg = errMsg || (e instanceof Error ? e.message : '') || '连接失败';
|
||||||
|
this.updateState('error', msg);
|
||||||
|
this.conn = null;
|
||||||
|
throw new Error(errMsg || '蓝牙连接失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发现服务并启用通知 */
|
||||||
|
private async discoverServices(device: BLEDevice): Promise<void> {
|
||||||
|
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
|
||||||
|
const services = servicesRes.services || [];
|
||||||
|
|
||||||
|
for (const { service: svcUUID, characteristic: charUUID } of device.adapter!.notifyCharacteristics) {
|
||||||
|
const svc = services.find((s: BLEServiceItem) =>
|
||||||
|
s.uuid.toUpperCase().includes(svcUUID.toUpperCase()),
|
||||||
|
);
|
||||||
|
if (!svc) continue;
|
||||||
|
|
||||||
|
await Taro.getBLEDeviceCharacteristics({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
serviceId: svc.uuid,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Taro.notifyBLECharacteristicValueChange({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
serviceId: svc.uuid,
|
||||||
|
characteristicId: charUUID,
|
||||||
|
state: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动读取特征值 */
|
||||||
|
async readCharacteristics(): Promise<NormalizedReading[]> {
|
||||||
|
if (!this.conn || this.conn.state !== 'connected') {
|
||||||
|
throw new Error('设备未连接');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { deviceId, adapter } = this.conn;
|
||||||
|
const results: NormalizedReading[] = [];
|
||||||
|
|
||||||
|
const servicesRes = await Taro.getBLEDeviceServices({ deviceId });
|
||||||
|
const services = servicesRes.services || [];
|
||||||
|
|
||||||
|
for (const { service: svcUUID, characteristic: charUUID } of adapter.readCharacteristics) {
|
||||||
|
const svc = services.find((s: BLEServiceItem) =>
|
||||||
|
s.uuid.toUpperCase().includes(svcUUID.toUpperCase()),
|
||||||
|
);
|
||||||
|
if (!svc) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Taro.readBLECharacteristicValue({
|
||||||
|
deviceId,
|
||||||
|
serviceId: svc.uuid,
|
||||||
|
characteristicId: charUUID,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[ble] 读取特征值失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length > 0) {
|
||||||
|
this.readings = [...this.readings, ...results];
|
||||||
|
this.onNewReadings(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 断开连接 */
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
if (!this.conn) return;
|
||||||
|
|
||||||
|
const { deviceId } = this.conn;
|
||||||
|
|
||||||
|
// 移除 BLE 监听器,防止断开后仍收到回调
|
||||||
|
if (this.connChangeHandler) {
|
||||||
|
Taro.offBLEConnectionStateChange(this.connChangeHandler);
|
||||||
|
this.connChangeHandler = null;
|
||||||
|
}
|
||||||
|
if (this.charChangeHandler) {
|
||||||
|
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
|
||||||
|
this.charChangeHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Taro.closeBLEConnection({ deviceId });
|
||||||
|
} catch {
|
||||||
|
// 忽略断连错误
|
||||||
|
}
|
||||||
|
|
||||||
|
this.conn = null;
|
||||||
|
this.readings = [];
|
||||||
|
this.updateState('disconnected');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,17 +2,15 @@ import Taro from '@tarojs/taro';
|
|||||||
import type {
|
import type {
|
||||||
DeviceAdapter,
|
DeviceAdapter,
|
||||||
BLEDevice,
|
BLEDevice,
|
||||||
BLEConnection,
|
BLEConnection as BLEConnectionInfo,
|
||||||
BLEConnectionState,
|
BLEConnectionState,
|
||||||
NormalizedReading,
|
NormalizedReading,
|
||||||
SyncResult,
|
SyncResult,
|
||||||
BLEManagerConfig,
|
BLEManagerConfig,
|
||||||
BLEScanResult,
|
BLEScanResult,
|
||||||
BLEConnectionChangeResult,
|
|
||||||
BLECharacteristicChangeResult,
|
|
||||||
BLEServiceItem,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import { DataBuffer } from './DataBuffer';
|
import { DataBuffer } from './DataBuffer';
|
||||||
|
import { BLEConnection } from './BLEConnection';
|
||||||
|
|
||||||
const DEFAULT_CONFIG: BLEManagerConfig = {
|
const DEFAULT_CONFIG: BLEManagerConfig = {
|
||||||
scanTimeout: 10000,
|
scanTimeout: 10000,
|
||||||
@@ -24,20 +22,27 @@ const MAX_LIVE_READINGS = 200;
|
|||||||
|
|
||||||
export class BLEManager {
|
export class BLEManager {
|
||||||
private adapters: DeviceAdapter[] = [];
|
private adapters: DeviceAdapter[] = [];
|
||||||
private connection: BLEConnection | null = null;
|
private bleConnection: BLEConnection;
|
||||||
private readings: NormalizedReading[] = [];
|
|
||||||
private dataBuffer: DataBuffer;
|
private dataBuffer: DataBuffer;
|
||||||
private config: BLEManagerConfig;
|
private config: BLEManagerConfig;
|
||||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private onConnectionChange?: (state: BLEConnectionState) => void;
|
private onConnectionChange?: (state: BLEConnectionState) => void;
|
||||||
private onReadings?: (readings: NormalizedReading[]) => void;
|
private onReadings?: (readings: NormalizedReading[]) => void;
|
||||||
private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null;
|
|
||||||
private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null;
|
|
||||||
|
|
||||||
constructor(config?: Partial<BLEManagerConfig>) {
|
constructor(config?: Partial<BLEManagerConfig>) {
|
||||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
this.dataBuffer = new DataBuffer();
|
this.dataBuffer = new DataBuffer();
|
||||||
this.dataBuffer.restore();
|
this.dataBuffer.restore();
|
||||||
|
|
||||||
|
this.bleConnection = new BLEConnection({
|
||||||
|
onStateChange: (state) => {
|
||||||
|
this.onConnectionChange?.(state);
|
||||||
|
},
|
||||||
|
onNewReadings: (readings) => {
|
||||||
|
this.dataBuffer.push(readings);
|
||||||
|
this.onReadings?.(readings);
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 注册设备适配器 */
|
/** 注册设备适配器 */
|
||||||
@@ -55,13 +60,6 @@ export class BLEManager {
|
|||||||
this.onReadings = cb;
|
this.onReadings = cb;
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateState(state: BLEConnectionState, error?: string): void {
|
|
||||||
if (this.connection) {
|
|
||||||
this.connection = { ...this.connection, state, error };
|
|
||||||
}
|
|
||||||
this.onConnectionChange?.(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 匹配设备到适配器 */
|
/** 匹配设备到适配器 */
|
||||||
private matchAdapter(deviceName: string): DeviceAdapter | undefined {
|
private matchAdapter(deviceName: string): DeviceAdapter | undefined {
|
||||||
const lower = deviceName.toLowerCase();
|
const lower = deviceName.toLowerCase();
|
||||||
@@ -136,137 +134,26 @@ export class BLEManager {
|
|||||||
|
|
||||||
/** 连接到设备 */
|
/** 连接到设备 */
|
||||||
async connect(device: BLEDevice): Promise<void> {
|
async connect(device: BLEDevice): Promise<void> {
|
||||||
if (!device.adapter) throw new Error('设备无适配器');
|
return this.bleConnection.connect(device, MAX_LIVE_READINGS);
|
||||||
|
|
||||||
this.connection = {
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
state: 'connecting',
|
|
||||||
adapter: device.adapter,
|
|
||||||
};
|
|
||||||
this.updateState('connecting');
|
|
||||||
this.readings = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Taro.createBLEConnection({
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
timeout: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 移除旧监听器,避免多次 connect 累积
|
|
||||||
if (this.connChangeHandler) {
|
|
||||||
Taro.offBLEConnectionStateChange(this.connChangeHandler);
|
|
||||||
}
|
|
||||||
if (this.charChangeHandler) {
|
|
||||||
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听断连
|
|
||||||
this.connChangeHandler = (res: BLEConnectionChangeResult) => {
|
|
||||||
if (res.deviceId === device.deviceId && !res.connected) {
|
|
||||||
this.updateState('disconnected', '设备断开连接');
|
|
||||||
this.connection = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Taro.onBLEConnectionStateChange(this.connChangeHandler);
|
|
||||||
|
|
||||||
// 发现服务
|
|
||||||
const servicesRes = await Taro.getBLEDeviceServices({ deviceId: device.deviceId });
|
|
||||||
const services = servicesRes.services || [];
|
|
||||||
|
|
||||||
// 启用通知
|
|
||||||
for (const { service: svcUUID, characteristic: charUUID } of device.adapter.notifyCharacteristics) {
|
|
||||||
const svc = services.find((s: BLEServiceItem) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
|
|
||||||
if (!svc) continue;
|
|
||||||
|
|
||||||
await Taro.getBLEDeviceCharacteristics({
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
serviceId: svc.uuid,
|
|
||||||
});
|
|
||||||
|
|
||||||
await Taro.notifyBLECharacteristicValueChange({
|
|
||||||
deviceId: device.deviceId,
|
|
||||||
serviceId: svc.uuid,
|
|
||||||
characteristicId: charUUID,
|
|
||||||
state: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 监听数据通知
|
|
||||||
this.charChangeHandler = (res: BLECharacteristicChangeResult) => {
|
|
||||||
if (res.deviceId !== device.deviceId) return;
|
|
||||||
const newReadings = device.adapter!.parseNotification(
|
|
||||||
res.serviceId,
|
|
||||||
res.characteristicId,
|
|
||||||
res.value,
|
|
||||||
);
|
|
||||||
if (newReadings.length > 0) {
|
|
||||||
const combined = [...this.readings, ...newReadings];
|
|
||||||
this.readings = combined.length > MAX_LIVE_READINGS
|
|
||||||
? combined.slice(-MAX_LIVE_READINGS)
|
|
||||||
: combined;
|
|
||||||
this.dataBuffer.push(newReadings);
|
|
||||||
this.onReadings?.(newReadings);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Taro.onBLECharacteristicValueChange(this.charChangeHandler);
|
|
||||||
|
|
||||||
this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() };
|
|
||||||
this.updateState('connected');
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const errMsg = (e as { errMsg?: string })?.errMsg;
|
|
||||||
const msg = errMsg || (e instanceof Error ? e.message : '') || '连接失败';
|
|
||||||
this.updateState('error', msg);
|
|
||||||
this.connection = null;
|
|
||||||
throw new Error(errMsg || '蓝牙连接失败');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动读取特征值 */
|
/** 手动读取特征值 */
|
||||||
async readCharacteristics(): Promise<NormalizedReading[]> {
|
async readCharacteristics(): Promise<NormalizedReading[]> {
|
||||||
if (!this.connection || this.connection.state !== 'connected') {
|
return this.bleConnection.readCharacteristics();
|
||||||
throw new Error('设备未连接');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { deviceId, adapter } = this.connection;
|
|
||||||
const results: NormalizedReading[] = [];
|
|
||||||
|
|
||||||
const servicesRes = await Taro.getBLEDeviceServices({ deviceId });
|
|
||||||
const services = servicesRes.services || [];
|
|
||||||
|
|
||||||
for (const { service: svcUUID, characteristic: charUUID } of adapter.readCharacteristics) {
|
|
||||||
const svc = services.find((s: BLEServiceItem) => s.uuid.toUpperCase().includes(svcUUID.toUpperCase()));
|
|
||||||
if (!svc) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Taro.readBLECharacteristicValue({
|
|
||||||
deviceId,
|
|
||||||
serviceId: svc.uuid,
|
|
||||||
characteristicId: charUUID,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.warn('[ble] 读取特征值失败:', err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (results.length > 0) {
|
|
||||||
this.readings = [...this.readings, ...results];
|
|
||||||
this.onReadings?.(results);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 同步收集的读数到后端 */
|
/** 同步收集的读数到后端 */
|
||||||
async syncToServer(uploadFn: (readings: NormalizedReading[]) => Promise<number>): Promise<SyncResult> {
|
async syncToServer(uploadFn: (readings: NormalizedReading[]) => Promise<number>): Promise<SyncResult> {
|
||||||
if (!this.connection) {
|
const conn = this.bleConnection.getConnection();
|
||||||
|
if (!conn) {
|
||||||
return { success: false, readingsCount: 0, uploadedCount: 0, error: '未连接设备' };
|
return { success: false, readingsCount: 0, uploadedCount: 0, error: '未连接设备' };
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateState('syncing');
|
this.onConnectionChange?.('syncing');
|
||||||
|
|
||||||
const batch = this.dataBuffer.flush();
|
const batch = this.dataBuffer.flush();
|
||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
this.updateState('connected');
|
this.onConnectionChange?.('connected');
|
||||||
return { success: true, readingsCount: 0, uploadedCount: 0 };
|
return { success: true, readingsCount: 0, uploadedCount: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,8 +161,8 @@ export class BLEManager {
|
|||||||
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFn(batch);
|
const uploaded = await uploadFn(batch);
|
||||||
this.readings = [];
|
this.bleConnection.clearReadings();
|
||||||
this.updateState('connected');
|
this.onConnectionChange?.('connected');
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
readingsCount: batch.length,
|
readingsCount: batch.length,
|
||||||
@@ -288,35 +175,13 @@ export class BLEManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.updateState('error', lastError);
|
this.onConnectionChange?.('error');
|
||||||
return { success: false, readingsCount: batch.length, uploadedCount: 0, error: lastError };
|
return { success: false, readingsCount: batch.length, uploadedCount: 0, error: lastError };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 断开连接 */
|
/** 断开连接 */
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
if (!this.connection) return;
|
return this.bleConnection.disconnect();
|
||||||
|
|
||||||
const { deviceId } = this.connection;
|
|
||||||
|
|
||||||
// 移除 BLE 监听器,防止断开后仍收到回调
|
|
||||||
if (this.connChangeHandler) {
|
|
||||||
Taro.offBLEConnectionStateChange(this.connChangeHandler);
|
|
||||||
this.connChangeHandler = null;
|
|
||||||
}
|
|
||||||
if (this.charChangeHandler) {
|
|
||||||
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
|
|
||||||
this.charChangeHandler = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Taro.closeBLEConnection({ deviceId });
|
|
||||||
} catch {
|
|
||||||
// 忽略断连错误
|
|
||||||
}
|
|
||||||
|
|
||||||
this.connection = null;
|
|
||||||
this.readings = [];
|
|
||||||
this.updateState('disconnected');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭蓝牙适配器 */
|
/** 关闭蓝牙适配器 */
|
||||||
@@ -330,18 +195,18 @@ export class BLEManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 获取当前连接信息 */
|
/** 获取当前连接信息 */
|
||||||
getConnection(): BLEConnection | null {
|
getConnection(): BLEConnectionInfo | null {
|
||||||
return this.connection;
|
return this.bleConnection.getConnection();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取缓存的读数 */
|
/** 获取缓存的读数 */
|
||||||
getCachedReadings(): NormalizedReading[] {
|
getCachedReadings(): NormalizedReading[] {
|
||||||
return [...this.readings];
|
return this.bleConnection.getCachedReadings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清除缓存读数 */
|
/** 清除缓存读数 */
|
||||||
clearReadings(): void {
|
clearReadings(): void {
|
||||||
this.readings = [];
|
this.bleConnection.clearReadings();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动时检查缓存,有未上传数据则自动重传 */
|
/** 启动时检查缓存,有未上传数据则自动重传 */
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
||||||
|
import { ResponseCache } from './request/cache';
|
||||||
|
import { ConcurrencyLimiter } from './request/limiter';
|
||||||
|
|
||||||
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||||||
|
|
||||||
@@ -24,107 +26,7 @@ function safeGet(key: string): string {
|
|||||||
return secureGet(key);
|
return secureGet(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Concurrency limiter ---
|
|
||||||
|
|
||||||
class ConcurrencyLimiter {
|
|
||||||
private active = 0;
|
|
||||||
private queue: Array<() => void> = [];
|
|
||||||
|
|
||||||
constructor(private max: number) {}
|
|
||||||
|
|
||||||
acquire(): Promise<void> {
|
|
||||||
if (this.active < this.max) {
|
|
||||||
this.active++;
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
return new Promise<void>((resolve) => { this.queue.push(resolve); });
|
|
||||||
}
|
|
||||||
|
|
||||||
release(): void {
|
|
||||||
this.active--;
|
|
||||||
const next = this.queue.shift();
|
|
||||||
if (next) { this.active++; next(); }
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.active = 0;
|
|
||||||
this.queue.length = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const limiter = new ConcurrencyLimiter(8);
|
const limiter = new ConcurrencyLimiter(8);
|
||||||
|
|
||||||
// --- Response cache + deduplication ---
|
|
||||||
|
|
||||||
interface CacheEntry { data: unknown; expiry: number }
|
|
||||||
|
|
||||||
class ResponseCache {
|
|
||||||
private cache = new Map<string, CacheEntry>();
|
|
||||||
private inflight = new Map<string, Promise<unknown>>();
|
|
||||||
private patientId = '';
|
|
||||||
|
|
||||||
constructor(private maxSize = 100, private defaultTtl = 60_000) {}
|
|
||||||
|
|
||||||
setPatientId(id: string): void {
|
|
||||||
if (this.patientId !== id) {
|
|
||||||
this.patientId = id;
|
|
||||||
this.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPatientId(): string { return this.patientId; }
|
|
||||||
|
|
||||||
private cacheKey(url: string): string {
|
|
||||||
return `${url}#${this.patientId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
get<T>(url: string): T | null {
|
|
||||||
const entry = this.cache.get(this.cacheKey(url));
|
|
||||||
if (entry && Date.now() < entry.expiry) return entry.data as T;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getInflight<T>(url: string): Promise<T> | null {
|
|
||||||
return (this.inflight.get(this.cacheKey(url)) as Promise<T> | undefined) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInflight(url: string, promise: Promise<unknown>): void {
|
|
||||||
this.inflight.set(this.cacheKey(url), promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
removeInflight(url: string): void {
|
|
||||||
this.inflight.delete(this.cacheKey(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
set(url: string, data: unknown, ttl?: number): void {
|
|
||||||
const key = this.cacheKey(url);
|
|
||||||
const effectiveTtl = ttl ?? this.defaultTtl;
|
|
||||||
if (effectiveTtl <= 0) return;
|
|
||||||
if (this.cache.size >= this.maxSize) {
|
|
||||||
const oldest = this.cache.keys().next().value;
|
|
||||||
if (oldest) this.cache.delete(oldest);
|
|
||||||
}
|
|
||||||
this.cache.set(key, { data, expiry: Date.now() + effectiveTtl });
|
|
||||||
}
|
|
||||||
|
|
||||||
clear(prefix?: string): void {
|
|
||||||
if (prefix) {
|
|
||||||
for (const key of this.cache.keys()) {
|
|
||||||
if (key.includes(prefix)) this.cache.delete(key);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.cache.clear();
|
|
||||||
}
|
|
||||||
this.inflight.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
reset(): void {
|
|
||||||
this.cache.clear();
|
|
||||||
this.inflight.clear();
|
|
||||||
this.patientId = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseCache = new ResponseCache();
|
const responseCache = new ResponseCache();
|
||||||
|
|
||||||
// --- Headers cache ---
|
// --- Headers cache ---
|
||||||
|
|||||||
75
apps/miniprogram/src/services/request/cache.ts
Normal file
75
apps/miniprogram/src/services/request/cache.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* ResponseCache — GET 请求结果缓存 + 请求去重(inflight dedup)
|
||||||
|
*
|
||||||
|
* 缓存条目按 `{url}#{patientId}` 做复合键,切换患者时自动清空。
|
||||||
|
* inflight map 保证同一个 URL 在并发场景下只发一次网络请求。
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CacheEntry { data: unknown; expiry: number }
|
||||||
|
|
||||||
|
export class ResponseCache {
|
||||||
|
private cache = new Map<string, CacheEntry>();
|
||||||
|
private inflight = new Map<string, Promise<unknown>>();
|
||||||
|
private patientId = '';
|
||||||
|
|
||||||
|
constructor(private maxSize = 100, private defaultTtl = 60_000) {}
|
||||||
|
|
||||||
|
setPatientId(id: string): void {
|
||||||
|
if (this.patientId !== id) {
|
||||||
|
this.patientId = id;
|
||||||
|
this.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPatientId(): string { return this.patientId; }
|
||||||
|
|
||||||
|
private cacheKey(url: string): string {
|
||||||
|
return `${url}#${this.patientId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(url: string): T | null {
|
||||||
|
const entry = this.cache.get(this.cacheKey(url));
|
||||||
|
if (entry && Date.now() < entry.expiry) return entry.data as T;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getInflight<T>(url: string): Promise<T> | null {
|
||||||
|
return (this.inflight.get(this.cacheKey(url)) as Promise<T> | undefined) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInflight(url: string, promise: Promise<unknown>): void {
|
||||||
|
this.inflight.set(this.cacheKey(url), promise);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeInflight(url: string): void {
|
||||||
|
this.inflight.delete(this.cacheKey(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
set(url: string, data: unknown, ttl?: number): void {
|
||||||
|
const key = this.cacheKey(url);
|
||||||
|
const effectiveTtl = ttl ?? this.defaultTtl;
|
||||||
|
if (effectiveTtl <= 0) return;
|
||||||
|
if (this.cache.size >= this.maxSize) {
|
||||||
|
const oldest = this.cache.keys().next().value;
|
||||||
|
if (oldest) this.cache.delete(oldest);
|
||||||
|
}
|
||||||
|
this.cache.set(key, { data, expiry: Date.now() + effectiveTtl });
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(prefix?: string): void {
|
||||||
|
if (prefix) {
|
||||||
|
for (const key of this.cache.keys()) {
|
||||||
|
if (key.includes(prefix)) this.cache.delete(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
this.inflight.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.inflight.clear();
|
||||||
|
this.patientId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/miniprogram/src/services/request/limiter.ts
Normal file
32
apps/miniprogram/src/services/request/limiter.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* ConcurrencyLimiter — 并发请求限制器
|
||||||
|
*
|
||||||
|
* 微信小程序 wx.request 并发上限为 10,默认配置 8 个槽位。
|
||||||
|
* acquire() 获取一个槽位(排队等待),release() 释放槽位。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ConcurrencyLimiter {
|
||||||
|
private active = 0;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
constructor(private max: number) {}
|
||||||
|
|
||||||
|
acquire(): Promise<void> {
|
||||||
|
if (this.active < this.max) {
|
||||||
|
this.active++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise<void>((resolve) => { this.queue.push(resolve); });
|
||||||
|
}
|
||||||
|
|
||||||
|
release(): void {
|
||||||
|
this.active--;
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) { this.active++; next(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.active = 0;
|
||||||
|
this.queue.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -97,3 +97,10 @@
|
|||||||
opacity: var(--tk-touch-feedback-opacity);
|
opacity: var(--tk-touch-feedback-opacity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@mixin fade-in($property: opacity, $duration: normal) {
|
||||||
|
$d: var(--tk-duration-normal);
|
||||||
|
@if $duration == fast { $d: var(--tk-duration-fast); }
|
||||||
|
@if $duration == slow { $d: var(--tk-duration-slow); }
|
||||||
|
transition: #{$property} $d var(--tk-easing);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
|
// Auto-generated by scripts/generate-tokens.ts — DO NOT EDIT
|
||||||
// Generated at: 2026-05-22T00:11:18.815Z
|
// Generated at: 2026-05-22T00:37:54.202Z
|
||||||
|
|
||||||
export const TOKEN_VALUES = {
|
export const TOKEN_VALUES = {
|
||||||
"pri": "#C4623A",
|
"pri": "#C4623A",
|
||||||
@@ -42,7 +42,12 @@ export const TOKEN_VALUES = {
|
|||||||
"touch-feedback-opacity": "0.85",
|
"touch-feedback-opacity": "0.85",
|
||||||
"tag-font-size": "11px",
|
"tag-font-size": "11px",
|
||||||
"tag-padding-v": "3px",
|
"tag-padding-v": "3px",
|
||||||
"tag-padding-h": "8px"
|
"tag-padding-h": "8px",
|
||||||
|
"duration-fast": "150ms",
|
||||||
|
"duration-normal": "200ms",
|
||||||
|
"duration-slow": "300ms",
|
||||||
|
"easing": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
"easing-bounce": "cubic-bezier(0.34, 1.56, 0.64, 1)"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const ELDER_TOKEN_OVERRIDES = {
|
export const ELDER_TOKEN_OVERRIDES = {
|
||||||
|
|||||||
@@ -56,6 +56,13 @@ page {
|
|||||||
--tk-tag-font-size: 11px;
|
--tk-tag-font-size: 11px;
|
||||||
--tk-tag-padding-v: 3px;
|
--tk-tag-padding-v: 3px;
|
||||||
--tk-tag-padding-h: 8px;
|
--tk-tag-padding-h: 8px;
|
||||||
|
|
||||||
|
// ─── 动画时序 ───
|
||||||
|
--tk-duration-fast: 150ms;
|
||||||
|
--tk-duration-normal: 200ms;
|
||||||
|
--tk-duration-slow: 300ms;
|
||||||
|
--tk-easing: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
--tk-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
|
|||||||
22
apps/miniprogram/src/utils/haptic.ts
Normal file
22
apps/miniprogram/src/utils/haptic.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import Taro from '@tarojs/taro';
|
||||||
|
|
||||||
|
/** 轻触反馈(按钮点击) */
|
||||||
|
export function hapticLight(): void {
|
||||||
|
try {
|
||||||
|
Taro.vibrateShort({ type: 'light' });
|
||||||
|
} catch { /* 部分设备不支持 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中等反馈(成功操作) */
|
||||||
|
export function hapticMedium(): void {
|
||||||
|
try {
|
||||||
|
Taro.vibrateShort({ type: 'medium' });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重度反馈(错误/警告) */
|
||||||
|
export function hapticHeavy(): void {
|
||||||
|
try {
|
||||||
|
Taro.vibrateShort({ type: 'heavy' });
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user