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 { View, Text } from '@tarojs/components';
|
||||
import { hapticLight } from '@/utils/haptic';
|
||||
import './index.scss';
|
||||
|
||||
interface PrimaryButtonProps {
|
||||
@@ -27,8 +28,14 @@ const PrimaryButton: React.FC<PrimaryButtonProps> = ({
|
||||
className,
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const handleClick = () => {
|
||||
if (disabled || loading) return;
|
||||
hapticLight();
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
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' />}
|
||||
<Text className='primary-btn__text'>{children}</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,223 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
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 PageShell from '@/components/ui/PageShell';
|
||||
import ContentCard from '@/components/ui/ContentCard';
|
||||
import {
|
||||
BP_RANGE, WEIGHT_RANGE, SUGAR_RANGE, VOLUME_RANGE,
|
||||
checkAbnormal, formatDate, FIELD_LABELS,
|
||||
type SectionKey,
|
||||
} from './constants';
|
||||
import { useDailyMonitoring } from './useDailyMonitoring';
|
||||
import './index.scss';
|
||||
|
||||
export default function DailyMonitoring() {
|
||||
const modeClass = useElderClass();
|
||||
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');
|
||||
const {
|
||||
dateIdx, setDateIdx, dateList, recordDate, isToday,
|
||||
morningSystolic, setMorningSystolic,
|
||||
morningDiastolic, setMorningDiastolic,
|
||||
eveningSystolic, setEveningSystolic,
|
||||
eveningDiastolic, setEveningDiastolic,
|
||||
weight, setWeight,
|
||||
bloodSugar, setBloodSugar,
|
||||
fluidIntake, setFluidIntake,
|
||||
urineOutput, setUrineOutput,
|
||||
notes, setNotes,
|
||||
submitting, collapsed, toggleSection,
|
||||
handleSubmit, resetForm,
|
||||
morningSysAbnormal, morningDiaAbnormal,
|
||||
eveningSysAbnormal, eveningDiaAbnormal,
|
||||
bloodSugarAbnormal,
|
||||
} = useDailyMonitoring();
|
||||
|
||||
return (
|
||||
<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 {
|
||||
DeviceAdapter,
|
||||
BLEDevice,
|
||||
BLEConnection,
|
||||
BLEConnection as BLEConnectionInfo,
|
||||
BLEConnectionState,
|
||||
NormalizedReading,
|
||||
SyncResult,
|
||||
BLEManagerConfig,
|
||||
BLEScanResult,
|
||||
BLEConnectionChangeResult,
|
||||
BLECharacteristicChangeResult,
|
||||
BLEServiceItem,
|
||||
} from './types';
|
||||
import { DataBuffer } from './DataBuffer';
|
||||
import { BLEConnection } from './BLEConnection';
|
||||
|
||||
const DEFAULT_CONFIG: BLEManagerConfig = {
|
||||
scanTimeout: 10000,
|
||||
@@ -24,20 +22,27 @@ const MAX_LIVE_READINGS = 200;
|
||||
|
||||
export class BLEManager {
|
||||
private adapters: DeviceAdapter[] = [];
|
||||
private connection: BLEConnection | null = null;
|
||||
private readings: NormalizedReading[] = [];
|
||||
private bleConnection: BLEConnection;
|
||||
private dataBuffer: DataBuffer;
|
||||
private config: BLEManagerConfig;
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onConnectionChange?: (state: BLEConnectionState) => void;
|
||||
private onReadings?: (readings: NormalizedReading[]) => void;
|
||||
private connChangeHandler: ((res: BLEConnectionChangeResult) => void) | null = null;
|
||||
private charChangeHandler: ((res: BLECharacteristicChangeResult) => void) | null = null;
|
||||
|
||||
constructor(config?: Partial<BLEManagerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.dataBuffer = new DataBuffer();
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
const lower = deviceName.toLowerCase();
|
||||
@@ -136,137 +134,26 @@ export class BLEManager {
|
||||
|
||||
/** 连接到设备 */
|
||||
async connect(device: BLEDevice): Promise<void> {
|
||||
if (!device.adapter) throw new Error('设备无适配器');
|
||||
|
||||
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 || '蓝牙连接失败');
|
||||
}
|
||||
return this.bleConnection.connect(device, MAX_LIVE_READINGS);
|
||||
}
|
||||
|
||||
/** 手动读取特征值 */
|
||||
async readCharacteristics(): Promise<NormalizedReading[]> {
|
||||
if (!this.connection || this.connection.state !== 'connected') {
|
||||
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;
|
||||
return this.bleConnection.readCharacteristics();
|
||||
}
|
||||
|
||||
/** 同步收集的读数到后端 */
|
||||
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: '未连接设备' };
|
||||
}
|
||||
|
||||
this.updateState('syncing');
|
||||
this.onConnectionChange?.('syncing');
|
||||
|
||||
const batch = this.dataBuffer.flush();
|
||||
if (batch.length === 0) {
|
||||
this.updateState('connected');
|
||||
this.onConnectionChange?.('connected');
|
||||
return { success: true, readingsCount: 0, uploadedCount: 0 };
|
||||
}
|
||||
|
||||
@@ -274,8 +161,8 @@ export class BLEManager {
|
||||
for (let attempt = 1; attempt <= this.config.retryCount; attempt++) {
|
||||
try {
|
||||
const uploaded = await uploadFn(batch);
|
||||
this.readings = [];
|
||||
this.updateState('connected');
|
||||
this.bleConnection.clearReadings();
|
||||
this.onConnectionChange?.('connected');
|
||||
return {
|
||||
success: true,
|
||||
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 };
|
||||
}
|
||||
|
||||
/** 断开连接 */
|
||||
async disconnect(): Promise<void> {
|
||||
if (!this.connection) return;
|
||||
|
||||
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');
|
||||
return this.bleConnection.disconnect();
|
||||
}
|
||||
|
||||
/** 关闭蓝牙适配器 */
|
||||
@@ -330,18 +195,18 @@ export class BLEManager {
|
||||
}
|
||||
|
||||
/** 获取当前连接信息 */
|
||||
getConnection(): BLEConnection | null {
|
||||
return this.connection;
|
||||
getConnection(): BLEConnectionInfo | null {
|
||||
return this.bleConnection.getConnection();
|
||||
}
|
||||
|
||||
/** 获取缓存的读数 */
|
||||
getCachedReadings(): NormalizedReading[] {
|
||||
return [...this.readings];
|
||||
return this.bleConnection.getCachedReadings();
|
||||
}
|
||||
|
||||
/** 清除缓存读数 */
|
||||
clearReadings(): void {
|
||||
this.readings = [];
|
||||
this.bleConnection.clearReadings();
|
||||
}
|
||||
|
||||
/** 启动时检查缓存,有未上传数据则自动重传 */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
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';
|
||||
|
||||
@@ -24,107 +26,7 @@ function safeGet(key: string): string {
|
||||
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);
|
||||
|
||||
// --- 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();
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
// Generated at: 2026-05-22T00:11:18.815Z
|
||||
// Generated at: 2026-05-22T00:37:54.202Z
|
||||
|
||||
export const TOKEN_VALUES = {
|
||||
"pri": "#C4623A",
|
||||
@@ -42,7 +42,12 @@ export const TOKEN_VALUES = {
|
||||
"touch-feedback-opacity": "0.85",
|
||||
"tag-font-size": "11px",
|
||||
"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;
|
||||
|
||||
export const ELDER_TOKEN_OVERRIDES = {
|
||||
|
||||
@@ -56,6 +56,13 @@ page {
|
||||
--tk-tag-font-size: 11px;
|
||||
--tk-tag-padding-v: 3px;
|
||||
--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