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:
@@ -5,6 +5,7 @@ import ErrorBoundary from './components/ErrorBoundary';
|
|||||||
import { flushEvents } from './services/analytics';
|
import { flushEvents } from './services/analytics';
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
import { useUIStore } from './stores/ui';
|
import { useUIStore } from './stores/ui';
|
||||||
|
import { useAlertPolling } from './hooks/useAlertPolling';
|
||||||
import { migrateLegacyStorage } from './utils/secure-storage';
|
import { migrateLegacyStorage } from './utils/secure-storage';
|
||||||
import './app.scss';
|
import './app.scss';
|
||||||
|
|
||||||
@@ -19,6 +20,9 @@ function App({ children }: PropsWithChildren<Record<string, unknown>>) {
|
|||||||
restoreUI();
|
restoreUI();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 告警轮询:登录态下自动监听 critical 告警
|
||||||
|
useAlertPolling();
|
||||||
|
|
||||||
// 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式)
|
// 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (process.env.NODE_ENV === 'production') return;
|
if (process.env.NODE_ENV === 'production') return;
|
||||||
|
|||||||
123
apps/miniprogram/src/hooks/useAlertPolling.ts
Normal file
123
apps/miniprogram/src/hooks/useAlertPolling.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,15 +64,15 @@ export default function Health() {
|
|||||||
if (!thresholds.length) return null;
|
if (!thresholds.length) return null;
|
||||||
const th = thresholds;
|
const th = thresholds;
|
||||||
if (type === 'blood_pressure') {
|
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;
|
return v?.threshold_value ?? 140;
|
||||||
}
|
}
|
||||||
if (type === 'heart_rate') {
|
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;
|
return v?.threshold_value ?? 100;
|
||||||
}
|
}
|
||||||
if (type === 'blood_sugar') {
|
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 v?.threshold_value ?? 6.1;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -74,14 +74,13 @@ export function useHealthOverview() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
const results = await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
refreshToday(),
|
refreshToday(),
|
||||||
loadTrend(activeTab),
|
loadTrend(activeTab),
|
||||||
loadAiSuggestions(),
|
loadAiSuggestions(),
|
||||||
loadAlertCount(),
|
loadAlertCount(),
|
||||||
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
|
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); }),
|
||||||
]);
|
]);
|
||||||
return results;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
usePageData(fetchData, {
|
usePageData(fetchData, {
|
||||||
|
|||||||
Reference in New Issue
Block a user