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:
iven
2026-05-22 08:41:12 +08:00
parent c9fe654d44
commit 4fcbf705ca
12 changed files with 655 additions and 477 deletions

View File

@@ -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>

View File

@@ -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}>

View File

@@ -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,
};
}

View 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');
}
}

View File

@@ -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();
}
/** 启动时检查缓存,有未上传数据则自动重传 */

View File

@@ -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 ---

View 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 = '';
}
}

View 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;
}
}

View File

@@ -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);
}

View File

@@ -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 = {

View File

@@ -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);
}
// ═══════════════════════════════════════

View 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 */ }
}