fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过
根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增, 叠加离线时固定 3s 抑制期后的请求洪泛。 修复: - app.config.ts 添加 lazyCodeLoading: requiredComponents 主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB - request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap) 后端不可达时自动延长抑制,防止请求风暴 - SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误 - AbortController polyfill 补齐小程序运行时缺失 - 健康首页/设备同步/健康档案/报告/设置页 UI 重构 - 文章页公开端点适配游客访问 - 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
This commit is contained in:
@@ -66,6 +66,7 @@ function persistQueue(): void {
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
const evt: AnalyticsEvent = {
|
||||
event,
|
||||
@@ -89,7 +90,10 @@ export function trackPageView(pageName: string, properties?: Record<string, unkn
|
||||
trackEvent('page_view', { page: pageName, ...properties });
|
||||
}
|
||||
|
||||
let flushDisabled = false;
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
if (flushDisabled) return;
|
||||
loadQueue();
|
||||
if (memoryQueue.length === 0) return;
|
||||
|
||||
@@ -99,9 +103,16 @@ export async function flushEvents(): Promise<void> {
|
||||
|
||||
try {
|
||||
await api.post('/analytics/batch', { events: batch });
|
||||
} catch (e) {
|
||||
// 静默失败,不打印错误避免控制台洪泛
|
||||
void e;
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg === '权限不足' || msg === '登录已过期') {
|
||||
// 权限不足或未认证,停止后续 flush 并丢弃队列
|
||||
flushDisabled = true;
|
||||
memoryQueue = [];
|
||||
persistQueue();
|
||||
return;
|
||||
}
|
||||
// 其他错误(网络等)保留队列重试
|
||||
memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE);
|
||||
persistQueue();
|
||||
}
|
||||
@@ -111,3 +122,8 @@ export function getQueueSize(): number {
|
||||
loadQueue();
|
||||
return memoryQueue.length;
|
||||
}
|
||||
|
||||
/** 登录/切换用户时调用,重新启用 flush */
|
||||
export function resetAnalyticsDisabled(): void {
|
||||
flushDisabled = false;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api } from './request';
|
||||
|
||||
export interface Article {
|
||||
@@ -41,6 +42,46 @@ export function buildCategoryTree(flat: ArticleCategory[]): ArticleCategory[] {
|
||||
return roots;
|
||||
}
|
||||
|
||||
/** 获取默认 tenant_id(用于公开 API 调用) */
|
||||
function getDefaultTenantId(): string {
|
||||
return Taro.getStorageSync('tenant_id') || process.env.TARO_APP_DEFAULT_TENANT_ID || '';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 公开端点(无需认证,游客可访问)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listPublicArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
category_id?: string;
|
||||
tag_id?: string;
|
||||
keyword?: string;
|
||||
}) {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<{ data: Article[]; total: number }>('/public/articles', {
|
||||
tenant_id: tenantId,
|
||||
page: params?.page ?? 1,
|
||||
page_size: params?.page_size ?? 20,
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPublicCategories() {
|
||||
const tenantId = getDefaultTenantId();
|
||||
return api.get<ArticleCategory[]>('/public/article-categories', {
|
||||
tenant_id: tenantId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 认证端点(需要登录)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function listArticles(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
@@ -60,11 +101,6 @@ export async function getArticleDetail(id: string) {
|
||||
return api.get<Article>(`/health/articles/${id}`);
|
||||
}
|
||||
|
||||
/** 公开文章详情(无需认证) */
|
||||
export async function getPublicArticleDetail(id: string) {
|
||||
return api.get<Article>(`/public/articles/${id}`);
|
||||
}
|
||||
|
||||
export async function listCategories() {
|
||||
return api.get<ArticleCategory[]>('/health/article-categories');
|
||||
}
|
||||
|
||||
@@ -70,10 +70,13 @@ export class BLEManager {
|
||||
|
||||
/** 初始化蓝牙适配器 */
|
||||
async initialize(): Promise<void> {
|
||||
console.log('[ble] 步骤1: 开始初始化蓝牙适配器...');
|
||||
try {
|
||||
await Taro.openBluetoothAdapter();
|
||||
console.log('[ble] 步骤1: 蓝牙适配器初始化成功');
|
||||
} catch (e: unknown) {
|
||||
const errMsg = e instanceof Error ? e.message : (e as { errMsg?: string })?.errMsg || '蓝牙初始化失败,请检查蓝牙是否开启';
|
||||
console.error('[ble] 步骤1: 蓝牙初始化失败:', errMsg);
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
@@ -83,38 +86,61 @@ export class BLEManager {
|
||||
await this.initialize();
|
||||
|
||||
const discovered = new Map<string, BLEDevice>();
|
||||
const allModelKeywords = this.adapters.flatMap((a) => a.supportedModels);
|
||||
|
||||
console.log('[ble] 步骤2: 注册的适配器:', this.adapters.map((a) => a.name));
|
||||
console.log('[ble] 步骤2: 匹配关键词:', allModelKeywords);
|
||||
|
||||
let scanDeviceCount = 0;
|
||||
|
||||
const onFound = (res: BLEScanResult) => {
|
||||
for (const device of res.devices || []) {
|
||||
const devices = res.devices || [];
|
||||
scanDeviceCount += devices.length;
|
||||
|
||||
for (const device of devices) {
|
||||
const name = device.name || device.localName || '';
|
||||
if (!name) continue;
|
||||
const adapter = this.matchAdapter(name);
|
||||
if (adapter) {
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter,
|
||||
});
|
||||
|
||||
// 每个新发现的设备都打印(最多前 30 个避免日志爆炸)
|
||||
if (discovered.size < 30 && !discovered.has(device.deviceId)) {
|
||||
console.log(`[ble] 发现设备: "${name}" (RSSI:${device.RSSI ?? '?'}, 匹配:${adapter?.name ?? '无'})`);
|
||||
}
|
||||
|
||||
discovered.set(device.deviceId, {
|
||||
deviceId: device.deviceId,
|
||||
name,
|
||||
RSSI: device.RSSI ?? 0,
|
||||
localName: device.localName,
|
||||
advertisData: device.advertisData,
|
||||
adapter: adapter ?? undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Taro.onBluetoothDeviceFound(onFound);
|
||||
|
||||
const allServiceUUIDs = this.adapters.flatMap((a) => a.serviceUUIDs);
|
||||
console.log('[ble] 步骤3: 开始扫描 (超时', this.config.scanTimeout, 'ms)...');
|
||||
// 不传 services 参数 — 扫描所有 BLE 设备,避免设备使用私有 UUID 被过滤掉
|
||||
await Taro.startBluetoothDevicesDiscovery({
|
||||
allowDuplicatesKey: false,
|
||||
services: allServiceUUIDs.length > 0 ? allServiceUUIDs : undefined,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
this.scanTimer = setTimeout(async () => {
|
||||
await this.stopScan();
|
||||
Taro.offBluetoothDeviceFound(onFound);
|
||||
resolve(Array.from(discovered.values()));
|
||||
|
||||
const results = Array.from(discovered.values());
|
||||
console.log('[ble] 步骤4: 扫描结束');
|
||||
console.log('[ble] 回调触发设备总数:', scanDeviceCount);
|
||||
console.log('[ble] 有名称的设备数:', discovered.size);
|
||||
console.log('[ble] 最终返回设备数:', results.length);
|
||||
if (results.length > 0) {
|
||||
console.log('[ble] 设备列表:', results.map((d) => `${d.name} (${d.adapter?.name ?? '无适配器'})`));
|
||||
}
|
||||
|
||||
resolve(results);
|
||||
}, this.config.scanTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,4 +142,30 @@ export const CustomBandAdapter = createGenericBleAdapter({
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
/** 华为手环/手表 BLE 适配器 */
|
||||
export const HuaweiBandAdapter = createGenericBleAdapter({
|
||||
name: 'Huawei Band',
|
||||
supportedModels: [
|
||||
'HUAWEI Band',
|
||||
'HUAWEI Watch',
|
||||
'Huawei Band',
|
||||
'Huawei Watch',
|
||||
'HW-B',
|
||||
'HUAW',
|
||||
'华为手环',
|
||||
'华为手表',
|
||||
],
|
||||
profiles: ['heart_rate', 'health_thermometer'],
|
||||
});
|
||||
|
||||
/**
|
||||
* 万能 fallback 适配器 — 匹配所有有名称的设备
|
||||
* 尝试标准 BLE 健康协议(心率/体温/血压),设备不支持的服务会被安全跳过
|
||||
*/
|
||||
export const FallbackAdapter = createGenericBleAdapter({
|
||||
name: '通用设备',
|
||||
supportedModels: [], // 不参与 matchAdapter,仅作为 fallback
|
||||
profiles: ['heart_rate', 'health_thermometer', 'blood_pressure'],
|
||||
});
|
||||
|
||||
export default CustomBandAdapter;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { XiaomiBandAdapter } from './XiaomiBandAdapter';
|
||||
export { BloodPressureAdapter } from './BloodPressureAdapter';
|
||||
export { GlucoseMeterAdapter } from './GlucoseMeterAdapter';
|
||||
export { CustomBandAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
export { CustomBandAdapter, HuaweiBandAdapter, FallbackAdapter, createGenericBleAdapter } from './GenericBleAdapter';
|
||||
|
||||
@@ -22,6 +22,38 @@ const ERROR_CODE_MAP: Record<string, string> = {
|
||||
CONCURRENCY_CONFLICT: '数据已被其他人修改,请刷新后重试',
|
||||
};
|
||||
|
||||
// --- 网络异常状态感知 ---
|
||||
// 检测到网络故障后,短时间内抑制后续请求,避免并发请求全部超时产生大量 toast
|
||||
// 连续失败时指数退避(3s → 6s → 12s → 30s),避免后端不可达时请求洪泛
|
||||
const OFFLINE_SUPPRESS_MS = 3000;
|
||||
const OFFLINE_MAX_MS = 30_000;
|
||||
let offlineDetectedAt = 0;
|
||||
let offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
let networkToastShown = false;
|
||||
let consecutiveNetErrors = 0;
|
||||
|
||||
function isOffline(): boolean {
|
||||
return offlineDetectedAt > 0 && Date.now() - offlineDetectedAt < offlineSuppressMs;
|
||||
}
|
||||
|
||||
function markOffline(): void {
|
||||
offlineDetectedAt = Date.now();
|
||||
consecutiveNetErrors++;
|
||||
// 指数退避:连续失败越多,抑制时间越长(3s → 6s → 12s → 30s cap)
|
||||
offlineSuppressMs = Math.min(OFFLINE_MAX_MS, OFFLINE_SUPPRESS_MS * Math.pow(2, consecutiveNetErrors - 1));
|
||||
if (!networkToastShown) {
|
||||
networkToastShown = true;
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none', duration: 2000 });
|
||||
setTimeout(() => { networkToastShown = false; }, offlineSuppressMs);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOffline(): void {
|
||||
offlineDetectedAt = 0;
|
||||
offlineSuppressMs = OFFLINE_SUPPRESS_MS;
|
||||
consecutiveNetErrors = 0;
|
||||
}
|
||||
|
||||
function safeGet(key: string): string {
|
||||
return secureGet(key);
|
||||
}
|
||||
@@ -139,6 +171,12 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
let retryCount401 = 0;
|
||||
for (;;) {
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
// 离线抑制:刚检测到网络故障时,直接跳过请求,避免 9+ 并发请求全部超时
|
||||
if (isOffline()) {
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
if (!bypassLimiter) await limiter.acquire();
|
||||
try {
|
||||
const headers = await getHeaders();
|
||||
@@ -153,10 +191,13 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
Taro.showToast({ title: '网络超时,请重试', icon: 'none' });
|
||||
throw new Error('网络超时');
|
||||
}
|
||||
Taro.showToast({ title: '网络异常,请检查连接', icon: 'none' });
|
||||
// 网络异常:标记离线 + toast 去重(3 秒内只弹一次)
|
||||
markOffline();
|
||||
throw new Error('网络异常');
|
||||
}
|
||||
|
||||
// 请求成功,清除离线标记
|
||||
clearOffline();
|
||||
if (signal?.aborted) throw new Error('请求已取消');
|
||||
|
||||
if (res.statusCode === 401) {
|
||||
@@ -181,7 +222,6 @@ async function request<T>(method: string, path: string, data?: unknown, timeout?
|
||||
}
|
||||
|
||||
if (res.statusCode === 403) {
|
||||
Taro.showToast({ title: '权限不足', icon: 'none' });
|
||||
throw new Error('权限不足');
|
||||
}
|
||||
|
||||
@@ -275,4 +315,6 @@ export function resetForTesting(): void {
|
||||
headersCacheTs = 0;
|
||||
refreshPromise = null;
|
||||
isLoggingOut = false;
|
||||
offlineDetectedAt = 0;
|
||||
networkToastShown = false;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user