Phase 0 安全修复: - 移除 secure-storage-aes.ts 硬编码 'hms-default-key' fallback - production 模式空密钥时拒绝加解密(返回空/不加密) - dev 模式保留明文兼容(warn 日志提醒) - .env/.env.h5 注入随机加密密钥 - secureGet 明文 fallback 按环境分级处理 - 新增 8 个测试覆盖空密钥 dev/production 行为 Phase 1 健康Tab重构: - health/index.tsx 从体征录入页改为健康总览Dashboard - 新增今日体征摘要卡片(2x2 网格 + 状态标签) - 新增快捷入口(录入体征/趋势/报告/用药) - 新增告警提示卡片(待处理告警数量) - 体征录入移至 pkg-health/input/index(已有页面) - useHealthData → useHealthOverview(新增 alertCount) 首页增强: - useHomeData 新增告警计数查询(listPatientAlerts) - 首页新增告警提示卡片入口 - "记录体征"按钮改为跳转录入页而非健康Tab
175 lines
6.6 KiB
TypeScript
175 lines
6.6 KiB
TypeScript
import { useState, useMemo, useRef, useEffect } from 'react';
|
|
import { useHealthStore } from '@/stores/health';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { usePageData } from '@/hooks/usePageData';
|
|
import { trackPageView } from '@/services/analytics';
|
|
import * as appointmentApi from '@/services/appointment';
|
|
import * as followupApi from '@/services/followup';
|
|
import { listPendingSuggestions, type AiSuggestionItem } from '@/services/ai-analysis';
|
|
import { notificationService } from '@/services/notification';
|
|
import { listPatientAlerts } from '@/services/alert';
|
|
|
|
export interface ReminderItem {
|
|
id: string;
|
|
text: string;
|
|
type: 'ai' | 'appointment' | 'followup';
|
|
tag: string;
|
|
}
|
|
|
|
function buildSuggestionText(s: AiSuggestionItem): string {
|
|
const riskMap: Record<string, string> = { high: '高风险', medium: '中风险', low: '低风险' };
|
|
const typeMap: Record<string, string> = {
|
|
vital_sign_anomaly: '体征异常',
|
|
lab_result_anomaly: '化验异常',
|
|
medication_adherence: '用药提醒',
|
|
lifestyle: '生活建议',
|
|
};
|
|
const risk = riskMap[s.risk_level] || '';
|
|
const type = typeMap[s.suggestion_type] || '健康建议';
|
|
return `${type}:发现${risk}指标,建议关注`;
|
|
}
|
|
|
|
export function useHomeData() {
|
|
const user = useAuthStore((s) => s.user);
|
|
const currentPatient = useAuthStore((s) => s.currentPatient);
|
|
const todaySummary = useHealthStore((s) => s.todaySummary);
|
|
const loading = useHealthStore((s) => s.loading);
|
|
const refreshToday = useHealthStore((s) => s.refreshToday);
|
|
const [reminders, setReminders] = useState<ReminderItem[]>([]);
|
|
const [unreadCount, setUnreadCount] = useState(0);
|
|
const [remindersLoading, setRemindersLoading] = useState(false);
|
|
const [alertCount, setAlertCount] = useState(0);
|
|
|
|
const fetchData = async () => {
|
|
const patientId = useAuthStore.getState().currentPatient?.id;
|
|
if (!patientId) return;
|
|
refreshToday();
|
|
loadReminders(patientId);
|
|
loadUnread();
|
|
loadAlertCount(patientId);
|
|
trackPageView('home');
|
|
};
|
|
|
|
const { trigger, refresh } = usePageData(fetchData, {
|
|
throttleMs: 5000,
|
|
enablePullDown: true,
|
|
enabled: !!user,
|
|
});
|
|
|
|
// currentPatient 从 null 变为有值时重新触发加载
|
|
// 解决 loadPatients 异步完成前 useDidShow 已触发 fetchData 并因 patientId 为空提前返回的问题
|
|
const prevPatientRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
const pid = currentPatient?.id ?? null;
|
|
if (pid && pid !== prevPatientRef.current) {
|
|
prevPatientRef.current = pid;
|
|
trigger();
|
|
}
|
|
}, [currentPatient?.id, trigger]);
|
|
|
|
const loadUnread = async () => {
|
|
try {
|
|
const res = await notificationService.getUnreadCount() as { data?: { count?: number } | number };
|
|
const d = res.data;
|
|
setUnreadCount(typeof d === 'object' && d ? (d.count ?? 0) : 0);
|
|
} catch (err) { console.warn('[home] 获取未读消息数失败:', err); }
|
|
};
|
|
|
|
const loadReminders = async (patientId: string) => {
|
|
setRemindersLoading(true);
|
|
try {
|
|
const items: ReminderItem[] = [];
|
|
const [apptRes, taskRes, suggestRes] = await Promise.allSettled([
|
|
appointmentApi.listAppointments(patientId, 1),
|
|
followupApi.listTasks(patientId, 'pending'),
|
|
listPendingSuggestions(),
|
|
]);
|
|
|
|
if (suggestRes.status === 'fulfilled') {
|
|
for (const s of suggestRes.value.slice(0, 1)) {
|
|
items.push({ id: s.id, text: buildSuggestionText(s), type: 'ai', tag: 'AI 建议' });
|
|
}
|
|
}
|
|
if (apptRes.status === 'fulfilled') {
|
|
for (const a of apptRes.value.data.slice(0, 1)) {
|
|
if (a.status === 'pending' || a.status === 'confirmed') {
|
|
items.push({
|
|
id: a.id,
|
|
text: `${a.appointment_date} ${a.start_time} — ${a.doctor_name || '医护'} ${a.department || '门诊'}`,
|
|
type: 'appointment',
|
|
tag: '预约',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
if (taskRes.status === 'fulfilled') {
|
|
for (const t of taskRes.value.data.slice(0, 1)) {
|
|
items.push({
|
|
id: t.id,
|
|
text: `${t.follow_up_type} · 截止 ${t.planned_date}`,
|
|
type: 'followup',
|
|
tag: '随访',
|
|
});
|
|
}
|
|
}
|
|
setReminders(items.slice(0, 3));
|
|
} catch (err) {
|
|
console.warn('[home] 加载提醒列表失败:', err);
|
|
setReminders([]);
|
|
} finally {
|
|
setRemindersLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadAlertCount = async (patientId: string) => {
|
|
try {
|
|
const res = await listPatientAlerts(patientId, { status: 'pending', page: 1, page_size: 1 });
|
|
setAlertCount(res.total ?? 0);
|
|
} catch {
|
|
setAlertCount(0);
|
|
}
|
|
};
|
|
|
|
const summary = todaySummary || {};
|
|
const indicators = [!!summary.blood_pressure, !!summary.heart_rate, !!summary.blood_sugar, !!summary.weight];
|
|
const completedCount = indicators.filter(Boolean).length;
|
|
const progressPercent = Math.round((completedCount / 4) * 100);
|
|
|
|
const indicatorCapsules = useMemo(() => [
|
|
{ label: '血压', done: !!summary.blood_pressure },
|
|
{ label: '心率', done: !!summary.heart_rate },
|
|
{ label: '血糖', done: !!summary.blood_sugar },
|
|
{ label: '体重', done: !!summary.weight },
|
|
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
|
|
|
|
const healthItems = useMemo(() => [
|
|
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status, indicator: 'systolic_bp_morning' },
|
|
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status, indicator: 'heart_rate' },
|
|
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status, indicator: 'blood_sugar' },
|
|
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status, indicator: 'weight' },
|
|
], [summary.blood_pressure, summary.heart_rate, summary.blood_sugar, summary.weight]);
|
|
|
|
const hour = new Date().getHours();
|
|
const greeting = hour < 12 ? '上午好' : hour < 18 ? '下午好' : '晚上好';
|
|
const displayName = user?.display_name || currentPatient?.name || user?.username || (user?.phone ? `${user.phone.slice(-4)}` : '') || '用户';
|
|
|
|
return {
|
|
user,
|
|
currentPatient,
|
|
todaySummary: summary,
|
|
loading,
|
|
reminders,
|
|
unreadCount,
|
|
remindersLoading,
|
|
alertCount,
|
|
indicatorCapsules,
|
|
healthItems,
|
|
completedCount,
|
|
progressPercent,
|
|
greeting,
|
|
displayName,
|
|
trigger,
|
|
refresh,
|
|
};
|
|
}
|