diff --git a/apps/miniprogram/src/app.tsx b/apps/miniprogram/src/app.tsx index 879dd81..7565dee 100644 --- a/apps/miniprogram/src/app.tsx +++ b/apps/miniprogram/src/app.tsx @@ -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>) { restoreUI(); }); + // 告警轮询:登录态下自动监听 critical 告警 + useAlertPolling(); + // 暴露全局 bridge 供 MCP/自动化测试调用(仅 dev 模式) useEffect(() => { if (process.env.NODE_ENV === 'production') return; diff --git a/apps/miniprogram/src/hooks/useAlertPolling.ts b/apps/miniprogram/src/hooks/useAlertPolling.ts new file mode 100644 index 0000000..34f7e10 --- /dev/null +++ b/apps/miniprogram/src/hooks/useAlertPolling.ts @@ -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 | 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({ + 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(); + } +} diff --git a/apps/miniprogram/src/pages/health/index.tsx b/apps/miniprogram/src/pages/health/index.tsx index 7e6014a..14aed5b 100644 --- a/apps/miniprogram/src/pages/health/index.tsx +++ b/apps/miniprogram/src/pages/health/index.tsx @@ -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; diff --git a/apps/miniprogram/src/pages/health/useHealthOverview.ts b/apps/miniprogram/src/pages/health/useHealthOverview.ts index 7e5abfa..07bb035 100644 --- a/apps/miniprogram/src/pages/health/useHealthOverview.ts +++ b/apps/miniprogram/src/pages/health/useHealthOverview.ts @@ -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, {