From 4fcbf705ca48399f8e2eca07ddcc312146cf2355 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 22 May 2026 08:41:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor(mp):=20E3-2=20=E5=A4=A7=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8B=86=E5=88=86=20+=20U3-2=20=E5=BE=AE=E4=BA=A4?= =?UTF-8?q?=E4=BA=92=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 三档) --- .../src/components/ui/PrimaryButton/index.tsx | 9 +- .../pkg-health/daily-monitoring/index.tsx | 230 ++--------------- .../daily-monitoring/useDailyMonitoring.ts | 238 ++++++++++++++++++ .../src/services/ble/BLEConnection.ts | 212 ++++++++++++++++ .../src/services/ble/BLEManager.ts | 189 ++------------ apps/miniprogram/src/services/request.ts | 102 +------- .../miniprogram/src/services/request/cache.ts | 75 ++++++ .../src/services/request/limiter.ts | 32 +++ apps/miniprogram/src/styles/mixins.scss | 7 + apps/miniprogram/src/styles/token-values.ts | 9 +- apps/miniprogram/src/styles/tokens.scss | 7 + apps/miniprogram/src/utils/haptic.ts | 22 ++ 12 files changed, 655 insertions(+), 477 deletions(-) create mode 100644 apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.ts create mode 100644 apps/miniprogram/src/services/ble/BLEConnection.ts create mode 100644 apps/miniprogram/src/services/request/cache.ts create mode 100644 apps/miniprogram/src/services/request/limiter.ts create mode 100644 apps/miniprogram/src/utils/haptic.ts diff --git a/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx b/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx index b61f141..afe2d7a 100644 --- a/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx +++ b/apps/miniprogram/src/components/ui/PrimaryButton/index.tsx @@ -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 = ({ className, ].filter(Boolean).join(' '); + const handleClick = () => { + if (disabled || loading) return; + hapticLight(); + onClick?.(); + }; + return ( - + {loading && } {children} diff --git a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx index 9514484..2949808 100644 --- a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx +++ b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/index.tsx @@ -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>({ - 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 ( diff --git a/apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.ts b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.ts new file mode 100644 index 0000000..bc8eaf2 --- /dev/null +++ b/apps/miniprogram/src/pages/pkg-health/daily-monitoring/useDailyMonitoring.ts @@ -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>({ + 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, + }; +} diff --git a/apps/miniprogram/src/services/ble/BLEConnection.ts b/apps/miniprogram/src/services/ble/BLEConnection.ts new file mode 100644 index 0000000..1c2f781 --- /dev/null +++ b/apps/miniprogram/src/services/ble/BLEConnection.ts @@ -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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/apps/miniprogram/src/services/ble/BLEManager.ts b/apps/miniprogram/src/services/ble/BLEManager.ts index 6e6a13e..be54e23 100644 --- a/apps/miniprogram/src/services/ble/BLEManager.ts +++ b/apps/miniprogram/src/services/ble/BLEManager.ts @@ -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 | 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) { 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 { - 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 { - 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): Promise { - 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 { - 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(); } /** 启动时检查缓存,有未上传数据则自动重传 */ diff --git a/apps/miniprogram/src/services/request.ts b/apps/miniprogram/src/services/request.ts index b6f6624..dc585db 100644 --- a/apps/miniprogram/src/services/request.ts +++ b/apps/miniprogram/src/services/request.ts @@ -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 { - if (this.active < this.max) { - this.active++; - return Promise.resolve(); - } - return new Promise((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(); - private inflight = new Map>(); - 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(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(url: string): Promise | null { - return (this.inflight.get(this.cacheKey(url)) as Promise | undefined) ?? null; - } - - setInflight(url: string, promise: Promise): 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 --- diff --git a/apps/miniprogram/src/services/request/cache.ts b/apps/miniprogram/src/services/request/cache.ts new file mode 100644 index 0000000..c97aa39 --- /dev/null +++ b/apps/miniprogram/src/services/request/cache.ts @@ -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(); + private inflight = new Map>(); + 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(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(url: string): Promise | null { + return (this.inflight.get(this.cacheKey(url)) as Promise | undefined) ?? null; + } + + setInflight(url: string, promise: Promise): 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 = ''; + } +} diff --git a/apps/miniprogram/src/services/request/limiter.ts b/apps/miniprogram/src/services/request/limiter.ts new file mode 100644 index 0000000..44918b5 --- /dev/null +++ b/apps/miniprogram/src/services/request/limiter.ts @@ -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 { + if (this.active < this.max) { + this.active++; + return Promise.resolve(); + } + return new Promise((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; + } +} diff --git a/apps/miniprogram/src/styles/mixins.scss b/apps/miniprogram/src/styles/mixins.scss index 0133f40..90a8b64 100644 --- a/apps/miniprogram/src/styles/mixins.scss +++ b/apps/miniprogram/src/styles/mixins.scss @@ -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); +} diff --git a/apps/miniprogram/src/styles/token-values.ts b/apps/miniprogram/src/styles/token-values.ts index 9352b99..bcb9f67 100644 --- a/apps/miniprogram/src/styles/token-values.ts +++ b/apps/miniprogram/src/styles/token-values.ts @@ -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 = { diff --git a/apps/miniprogram/src/styles/tokens.scss b/apps/miniprogram/src/styles/tokens.scss index a852847..504125b 100644 --- a/apps/miniprogram/src/styles/tokens.scss +++ b/apps/miniprogram/src/styles/tokens.scss @@ -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); } // ═══════════════════════════════════════ diff --git a/apps/miniprogram/src/utils/haptic.ts b/apps/miniprogram/src/utils/haptic.ts new file mode 100644 index 0000000..2e179d5 --- /dev/null +++ b/apps/miniprogram/src/utils/haptic.ts @@ -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 */ } +}