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:
iven
2026-05-24 11:32:40 +08:00
parent 675f8a4b10
commit 1e59007bd5
58 changed files with 4950 additions and 494 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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