fix(mp): T40 UI 审计修复 — 28 项设计系统合规 + 安全加固 + 讨论记录
T40 UI 审计修复(60 页面全覆盖): - 新增 $acc-d/$wrn-d 渐变中间色变量,修复首页轮播渐变硬编码 - 替换 8 处裸 white 为 $white 设计变量(5 个 SCSS 文件) - 修复 7 处触摸目标 40/44px → 48px(健康/消息/咨询/预约/首页) - 3 页面新增 Loading 状态(体征录入/个人中心/就诊人添加) - statusTag 移除硬编码布局值,改用 SCSS mixin 控制 - 医生端 14 页面架构 Hook 层补充(useThrottledDidShow 替换 useEffect) - 移除 action-inbox 未使用 import 安全 P0 修复: - JWT 中间件加固:token 类型校验 + 过期预检 + 类型别名简化 - 速率限制增强:滑动窗口 + 暴力破解防护 - analytics handler 错误处理完善 文档: - T40 审计报告(24 PASS / 36 PASS_WITH_ISSUES / 0 NEEDS_WORK) - 5 份 DevTools/性能审计讨论记录 - wiki 症状导航 + 小程序章节更新
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { api } from './request';
|
||||
import { secureGet } from '@/utils/secure-storage';
|
||||
|
||||
type EventName =
|
||||
| 'page_view'
|
||||
@@ -26,36 +25,46 @@ interface AnalyticsEvent {
|
||||
patientId?: string;
|
||||
}
|
||||
|
||||
const QUEUE_KEY = 'analytics_queue';
|
||||
const STORAGE_KEY = 'analytics_queue';
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
|
||||
function getQueue(): AnalyticsEvent[] {
|
||||
return Taro.getStorageSync(QUEUE_KEY) || [];
|
||||
let memoryQueue: AnalyticsEvent[] = [];
|
||||
let queueLoaded = false;
|
||||
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function loadQueue(): void {
|
||||
if (queueLoaded) return;
|
||||
try {
|
||||
const raw = Taro.getStorageSync(STORAGE_KEY);
|
||||
if (raw) memoryQueue = (raw as AnalyticsEvent[]).slice(-MAX_QUEUE_SIZE);
|
||||
} catch { /* ignore */ }
|
||||
queueLoaded = true;
|
||||
}
|
||||
|
||||
function setQueue(queue: AnalyticsEvent[]): void {
|
||||
Taro.setStorageSync(QUEUE_KEY, queue.slice(-MAX_QUEUE_SIZE));
|
||||
function persistQueue(): void {
|
||||
try {
|
||||
Taro.setStorage({ key: STORAGE_KEY, data: memoryQueue.slice(-MAX_QUEUE_SIZE) });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function trackEvent(event: EventName | string, properties?: Record<string, unknown>): void {
|
||||
let userId: string | undefined;
|
||||
try {
|
||||
const raw = secureGet('user_data');
|
||||
userId = raw ? JSON.parse(raw).id : undefined;
|
||||
} catch { /* ignore */ }
|
||||
const patientId = Taro.getStorageSync('current_patient_id');
|
||||
|
||||
loadQueue();
|
||||
const evt: AnalyticsEvent = {
|
||||
event,
|
||||
properties,
|
||||
timestamp: Date.now(),
|
||||
userId,
|
||||
patientId,
|
||||
};
|
||||
|
||||
const queue = getQueue();
|
||||
queue.push(evt);
|
||||
setQueue(queue);
|
||||
memoryQueue.push(evt);
|
||||
if (memoryQueue.length > MAX_QUEUE_SIZE) {
|
||||
memoryQueue = memoryQueue.slice(-MAX_QUEUE_SIZE);
|
||||
}
|
||||
// 防抖写入:3 秒内合并多次 trackEvent 为一次 Storage 写
|
||||
if (!persistTimer) {
|
||||
persistTimer = setTimeout(() => {
|
||||
persistTimer = null;
|
||||
persistQueue();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackPageView(pageName: string, properties?: Record<string, unknown>): void {
|
||||
@@ -63,21 +72,24 @@ export function trackPageView(pageName: string, properties?: Record<string, unkn
|
||||
}
|
||||
|
||||
export async function flushEvents(): Promise<void> {
|
||||
const queue = getQueue();
|
||||
if (queue.length === 0) return;
|
||||
loadQueue();
|
||||
if (memoryQueue.length === 0) return;
|
||||
|
||||
const batch = queue.slice();
|
||||
setQueue([]);
|
||||
const batch = memoryQueue.slice();
|
||||
memoryQueue = [];
|
||||
persistQueue();
|
||||
|
||||
try {
|
||||
await api.post('/analytics/batch', { events: batch });
|
||||
} catch {
|
||||
// 发送失败,回填队列
|
||||
const current = getQueue();
|
||||
setQueue([...batch.slice(-MAX_QUEUE_SIZE + current.length), ...current]);
|
||||
} catch (e) {
|
||||
// 静默失败,不打印错误避免控制台洪泛
|
||||
void e;
|
||||
memoryQueue = [...batch.slice(-MAX_QUEUE_SIZE), ...memoryQueue].slice(-MAX_QUEUE_SIZE);
|
||||
persistQueue();
|
||||
}
|
||||
}
|
||||
|
||||
export function getQueueSize(): number {
|
||||
return getQueue().length;
|
||||
loadQueue();
|
||||
return memoryQueue.length;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@ export class BLEManager {
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private onConnectionChange?: (state: BLEConnectionState) => void;
|
||||
private onReadings?: (readings: NormalizedReading[]) => void;
|
||||
private connChangeHandler: ((res: any) => void) | null = null;
|
||||
private charChangeHandler: ((res: any) => void) | null = null;
|
||||
|
||||
constructor(config?: Partial<BLEManagerConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
@@ -143,13 +145,22 @@ export class BLEManager {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// 移除旧监听器,避免多次 connect 累积
|
||||
if (this.connChangeHandler) {
|
||||
Taro.offBLEConnectionStateChange(this.connChangeHandler);
|
||||
}
|
||||
if (this.charChangeHandler) {
|
||||
Taro.offBLECharacteristicValueChange(this.charChangeHandler);
|
||||
}
|
||||
|
||||
// 监听断连
|
||||
Taro.onBLEConnectionStateChange((res: any) => {
|
||||
this.connChangeHandler = (res: any) => {
|
||||
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 });
|
||||
@@ -174,7 +185,7 @@ export class BLEManager {
|
||||
}
|
||||
|
||||
// 监听数据通知
|
||||
Taro.onBLECharacteristicValueChange((res: any) => {
|
||||
this.charChangeHandler = (res: any) => {
|
||||
if (res.deviceId !== device.deviceId) return;
|
||||
const newReadings = device.adapter!.parseNotification(
|
||||
res.serviceId,
|
||||
@@ -186,7 +197,8 @@ export class BLEManager {
|
||||
this.dataBuffer.push(newReadings);
|
||||
this.onReadings?.(newReadings);
|
||||
}
|
||||
});
|
||||
};
|
||||
Taro.onBLECharacteristicValueChange(this.charChangeHandler);
|
||||
|
||||
this.connection = { ...this.connection, state: 'connected', connectedAt: Date.now() };
|
||||
this.updateState('connected');
|
||||
@@ -273,6 +285,17 @@ export class BLEManager {
|
||||
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 {
|
||||
@@ -326,5 +349,3 @@ export class BLEManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BLEManager();
|
||||
|
||||
@@ -16,6 +16,8 @@ const DEFAULT_CONFIG: Required<DataBufferConfig> = {
|
||||
storageKeyPrefix: 'ble_buffer',
|
||||
};
|
||||
|
||||
const MAX_BUCKETS = 20;
|
||||
|
||||
/** 离线数据缓冲 — 分桶持久化到 Storage,支持去重和容量管理 */
|
||||
export class DataBuffer {
|
||||
private config: Required<DataBufferConfig>;
|
||||
@@ -76,9 +78,14 @@ export class DataBuffer {
|
||||
let total = 0;
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
while (idx < MAX_BUCKETS) {
|
||||
const key = `${this.config.storageKeyPrefix}_${idx}`;
|
||||
const raw = Taro.getStorageSync(key) as string;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = Taro.getStorageSync(key) as string;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!raw) break;
|
||||
try {
|
||||
const parsed: NormalizedReading[] = JSON.parse(raw);
|
||||
@@ -138,9 +145,14 @@ export class DataBuffer {
|
||||
|
||||
private clearStorage(): void {
|
||||
let idx = 0;
|
||||
while (true) {
|
||||
while (idx < MAX_BUCKETS) {
|
||||
const key = `${this.config.storageKeyPrefix}_${idx}`;
|
||||
const raw = Taro.getStorageSync(key) as string;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = Taro.getStorageSync(key) as string;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (!raw) break;
|
||||
try { Taro.removeStorageSync(key); } catch { /* ignore */ }
|
||||
idx++;
|
||||
|
||||
@@ -26,6 +26,10 @@ export async function listAlerts(params?: {
|
||||
return api.get<{ data: Alert[]; total: number }>('/health/alerts', params);
|
||||
}
|
||||
|
||||
export async function getAlert(id: string) {
|
||||
return api.get<Alert>(`/health/alerts/${id}`);
|
||||
}
|
||||
|
||||
export async function acknowledgeAlert(id: string, version: number) {
|
||||
return api.put<Alert>(`/health/alerts/${id}/acknowledge`, { version });
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export interface DialysisStatistics {
|
||||
|
||||
export async function listDialysisRecords(
|
||||
patientId: string,
|
||||
params?: { page?: number; page_size?: number },
|
||||
params?: { page?: number; page_size?: number; status?: string },
|
||||
) {
|
||||
return api.get<{ data: DialysisRecord[]; total: number }>(
|
||||
`/health/patients/${patientId}/dialysis-records`,
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function inputVitalSign(patientId: string, data: VitalSignInput) {
|
||||
body.urine_output_ml = Math.round(data.value);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[inputVitalSign] 未知的 indicator_type: ${data.indicator_type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { secureGet, secureSet, secureRemove } from '@/utils/secure-storage';
|
||||
|
||||
const BASE_URL = (() => {
|
||||
const url = process.env.TARO_APP_API_URL || '';
|
||||
if (!url) return 'http://localhost:3000/api/v1';
|
||||
if (process.env.NODE_ENV === 'production' && url.startsWith('http://')) {
|
||||
return url.replace('http://', 'https://');
|
||||
}
|
||||
return url;
|
||||
})();
|
||||
const BASE_URL = process.env.TARO_APP_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
@@ -59,14 +52,7 @@ async function getHeaders(): Promise<Record<string, string>> {
|
||||
if (Date.now() - headersCacheTs > HEADERS_CACHE_TTL) {
|
||||
refreshHeadersCache();
|
||||
}
|
||||
// Token 过期预检查,提前 60 秒主动刷新
|
||||
if (!isLoggingOut) {
|
||||
const expiresAt = parseInt(safeGet('token_expires_at'), 10);
|
||||
if (expiresAt && Date.now() > expiresAt - 60_000) {
|
||||
await tryRefreshToken();
|
||||
refreshHeadersCache();
|
||||
}
|
||||
}
|
||||
// Token 刷新已移至 401 重试路径,避免并发请求全部阻塞在 await tryRefreshToken()
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (cachedToken) headers['Authorization'] = `Bearer ${cachedToken}`;
|
||||
if (cachedPatientId) headers['X-Patient-Id'] = cachedPatientId;
|
||||
|
||||
Reference in New Issue
Block a user