feat(mp): App 级告警长轮询 + 健康总览 TS 修复

- 新增 useAlertPolling hook:10s 间隔轮询 critical 告警
- requestUnlimited 独立通道,不占并发槽位
- generation counter 防重叠 + 失败指数退避(max 30s/10次)
- 新告警弹窗 Taro.showModal + TabBar 角标
- 修复 HealthThreshold 属性名(indicator/level 非 indicator_name/severity)
- 修复 usePageData fetchData 返回类型
This commit is contained in:
iven
2026-05-22 12:06:02 +08:00
parent d24aefe750
commit 0dfbe3130c
4 changed files with 131 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ import ErrorBoundary from './components/ErrorBoundary';
import { flushEvents } from './services/analytics';
import { useAuthStore } from './stores/auth';
import { useUIStore } from './stores/ui';
import { useAlertPolling } from './hooks/useAlertPolling';
import { migrateLegacyStorage } from './utils/secure-storage';
import './app.scss';
@@ -19,6 +20,9 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
restoreUI();
});
// 告警轮询:登录态下自动监听 critical 告警
useAlertPolling();
// 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式)
useEffect(() => {
if (process.env.NODE_ENV === 'production') return;

View File

@@ -0,0 +1,123 @@
import { useRef, useCallback } from 'react';
import Taro from '@tarojs/taro';
import { useDidShow, useDidHide } from '@tarojs/taro';
import { requestUnlimited } from '@/services/request';
import { useAuthStore } from '@/stores/auth';
import type { Alert } from '@/services/alert';
interface PollState {
generation: number;
timer: ReturnType<typeof setTimeout> | null;
failCount: number;
lastAlertCount: number;
}
const POLL_INTERVAL = 10_000;
const MAX_FAILURES = 10;
const FAIL_BACKOFF_BASE = 2000;
const FAIL_BACKOFF_MAX = 30_000;
/**
* App 级告警长轮询。
*
* - 登录态 + 有 patientId 时启动
* - 使用 requestUnlimited 走独立通道,不占并发槽位
* - generation counter 防止重叠
* - useDidShow/Hide 控制前后台
* - critical 告警弹窗提醒 + TabBar 角标
*/
export function useAlertPolling() {
const stateRef = useRef<PollState>({
generation: 0,
timer: null,
failCount: 0,
lastAlertCount: 0,
});
const patientId = useAuthStore((s) => s.currentPatient?.id);
const isLoggedIn = useAuthStore((s) => !!s.user);
const enabled = isLoggedIn && !!patientId;
const poll = useCallback(async (gen: number, failCount: number) => {
const s = stateRef.current;
if (gen !== s.generation) return;
if (failCount >= MAX_FAILURES) return;
try {
const pid = useAuthStore.getState().currentPatient?.id;
if (!pid) return;
const res = await requestUnlimited<{ data: Alert[]; total: number }>(
'GET',
`/health/alerts?patient_id=${pid}&status=pending&severity=critical&page=1&page_size=5`,
undefined,
8000,
);
if (gen !== s.generation) return;
const count = res.total ?? 0;
// TabBar 角标
try {
if (count > 0) {
Taro.setTabBarBadge({ index: 2, text: String(count) });
} else {
Taro.removeTabBarBadge({ index: 2 });
}
} catch { /* TabBar 可能不存在 */ }
// 告警数量增加时弹窗提醒
if (count > s.lastAlertCount) {
Taro.showModal({
title: '健康告警',
content: `您有 ${count} 条待处理的危急值告警,请及时关注`,
showCancel: false,
confirmText: '知道了',
});
}
s.lastAlertCount = count;
failCount = 0;
} catch {
failCount++;
}
if (gen !== s.generation) return;
const delay = failCount > 0 ? Math.min(failCount * FAIL_BACKOFF_BASE, FAIL_BACKOFF_MAX) : POLL_INTERVAL;
s.timer = setTimeout(() => {
if (gen === s.generation) poll(gen, failCount);
}, delay);
}, []);
const start = useCallback(() => {
const s = stateRef.current;
s.generation++;
s.failCount = 0;
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
poll(s.generation, 0);
}, [poll]);
const stop = useCallback(() => {
const s = stateRef.current;
s.generation++;
if (s.timer) { clearTimeout(s.timer); s.timer = null; }
try { Taro.removeTabBarBadge({ index: 2 }); } catch { /* ignore */ }
}, []);
useDidShow(() => {
if (enabled) start();
});
useDidHide(() => {
stop();
});
// enabled 变化时启停
const prevEnabledRef = useRef(enabled);
if (enabled !== prevEnabledRef.current) {
prevEnabledRef.current = enabled;
if (enabled) start();
else stop();
}
}

View File

@@ -64,15 +64,15 @@ export default function Health() {
if (!thresholds.length) return null;
const th = thresholds;
if (type === 'blood_pressure') {
const v = th.find((t) => t.indicator_name === 'systolic_bp' && t.severity === 'high');
const v = th.find((t) => t.indicator === 'systolic_bp' && t.level === 'high');
return v?.threshold_value ?? 140;
}
if (type === 'heart_rate') {
const v = th.find((t) => t.indicator_name === 'heart_rate' && t.severity === 'high');
const v = th.find((t) => t.indicator === 'heart_rate' && t.level === 'high');
return v?.threshold_value ?? 100;
}
if (type === 'blood_sugar') {
const v = th.find((t) => t.indicator_name === 'blood_sugar_fasting' && t.severity === 'high');
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
return v?.threshold_value ?? 6.1;
}
return null;

View File

@@ -74,14 +74,13 @@ export function useHealthOverview() {
};
const fetchData = async () => {
const results = await Promise.allSettled([
await Promise.allSettled([
refreshToday(),
loadTrend(activeTab),
loadAiSuggestions(),
loadAlertCount(),
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
]);
return results;
};
usePageData(fetchData, {