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

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