- 新增 useAlertPolling hook:10s 间隔轮询 critical 告警 - requestUnlimited 独立通道,不占并发槽位 - generation counter 防重叠 + 失败指数退避(max 30s/10次) - 新告警弹窗 Taro.showModal + TabBar 角标 - 修复 HealthThreshold 属性名(indicator/level 非 indicator_name/severity) - 修复 usePageData fetchData 返回类型
227 lines
9.7 KiB
TypeScript
227 lines
9.7 KiB
TypeScript
import { View, Text } from '@tarojs/components';
|
||
import Taro from '@tarojs/taro';
|
||
import { safeNavigateTo } from '@/utils/navigate';
|
||
import { useAuthStore } from '../../stores/auth';
|
||
import { useElderClass } from '../../hooks/useElderClass';
|
||
import GuestGuard from '../../components/GuestGuard';
|
||
import Loading from '../../components/Loading';
|
||
import PageShell from '@/components/ui/PageShell';
|
||
import ContentCard from '@/components/ui/ContentCard';
|
||
import SegmentTabs from '../../components/SegmentTabs';
|
||
import { useHealthOverview, VITAL_TABS, type VitalType } from './useHealthOverview';
|
||
import { submitSuggestionFeedback } from '../../services/ai-analysis';
|
||
import './index.scss';
|
||
|
||
const QUICK_ENTRIES = [
|
||
{ label: '录入体征', icon: '笔', path: '/pages/pkg-health/input/index' },
|
||
{ label: '健康趋势', icon: '线', path: '/pages/pkg-health/trend/index' },
|
||
{ label: '我的报告', icon: '报', path: '/pages/pkg-profile/reports/index' },
|
||
{ label: '用药记录', icon: '药', path: '/pages/pkg-profile/medication/index' },
|
||
] as const;
|
||
|
||
function statusClass(status?: string): string {
|
||
if (!status) return '';
|
||
if (status === 'high' || status === 'abnormal') return 'vital-warn';
|
||
if (status === 'low') return 'vital-warn';
|
||
return 'vital-ok';
|
||
}
|
||
|
||
export default function Health() {
|
||
const user = useAuthStore((s) => s.user);
|
||
const modeClass = useElderClass();
|
||
const {
|
||
todaySummary, loading, error, activeTab, trendData, trendLoading,
|
||
aiSuggestions, thresholds, alertCount, handleTabChange, fetchData,
|
||
} = useHealthOverview();
|
||
|
||
if (!user) {
|
||
return <GuestGuard title='请先登录' desc='登录后即可查看健康数据' />;
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<PageShell padding="md" safeBottom={false} scroll={false} className={`health-page ${modeClass}`}>
|
||
<View className='health-header'>
|
||
<Text className='health-title'>健康总览</Text>
|
||
</View>
|
||
<Loading />
|
||
</PageShell>
|
||
);
|
||
}
|
||
|
||
const maxTrendValue = trendData.reduce((max, d) => Math.max(max, d.value), 1);
|
||
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
|
||
|
||
const summary = todaySummary || {};
|
||
const vitals = [
|
||
{ label: '血压', value: summary.blood_pressure ? `${summary.blood_pressure.systolic}/${summary.blood_pressure.diastolic}` : '—', unit: 'mmHg', status: summary.blood_pressure?.status },
|
||
{ label: '心率', value: summary.heart_rate ? `${summary.heart_rate.value}` : '—', unit: 'bpm', status: summary.heart_rate?.status },
|
||
{ label: '血糖', value: summary.blood_sugar ? `${summary.blood_sugar.value}` : '—', unit: 'mmol/L', status: summary.blood_sugar?.status },
|
||
{ label: '体重', value: summary.weight ? `${summary.weight.value}` : '—', unit: 'kg', status: summary.weight?.status },
|
||
];
|
||
|
||
const getThresholdValue = (type: VitalType): number | null => {
|
||
if (!thresholds.length) return null;
|
||
const th = thresholds;
|
||
if (type === 'blood_pressure') {
|
||
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 === 'heart_rate' && t.level === 'high');
|
||
return v?.threshold_value ?? 100;
|
||
}
|
||
if (type === 'blood_sugar') {
|
||
const v = th.find((t) => t.indicator === 'blood_sugar_fasting' && t.level === 'high');
|
||
return v?.threshold_value ?? 6.1;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return (
|
||
<PageShell padding="md" safeBottom={false} scroll className={`health-page ${modeClass}`}>
|
||
<View className='health-header'>
|
||
<Text className='health-title'>健康总览</Text>
|
||
</View>
|
||
|
||
{/* 今日体征摘要 */}
|
||
<ContentCard variant="elevated" className='vitals-grid'>
|
||
{loading ? <Loading /> : (
|
||
<View className='vitals-row'>
|
||
{vitals.map((v) => (
|
||
<View className={`vital-cell ${statusClass(v.status)}`} key={v.label}>
|
||
<Text className='vital-value'>{v.value}</Text>
|
||
<Text className='vital-unit'>{v.unit}</Text>
|
||
<Text className='vital-label'>{v.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
)}
|
||
</ContentCard>
|
||
|
||
{/* 快捷入口 */}
|
||
<View className='quick-entries'>
|
||
{QUICK_ENTRIES.map((e) => (
|
||
<View
|
||
key={e.label}
|
||
className='quick-entry'
|
||
onClick={() => safeNavigateTo(e.path)}
|
||
>
|
||
<View className='quick-icon'>
|
||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||
</View>
|
||
<Text className='quick-label'>{e.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{/* 告警提示 */}
|
||
{alertCount > 0 && (
|
||
<ContentCard
|
||
variant="elevated"
|
||
className='alert-hint'
|
||
onPress={() => safeNavigateTo('/pages/pkg-health/alerts/index')}
|
||
>
|
||
<View className='alert-dot' />
|
||
<Text className='alert-text'>{alertCount} 条待处理告警</Text>
|
||
<Text className='alert-arrow'>›</Text>
|
||
</ContentCard>
|
||
)}
|
||
|
||
{/* AI 建议 */}
|
||
{aiSuggestions.length > 0 && (
|
||
<View className='ai-suggestion-card'>
|
||
<View className='ai-card-header'>
|
||
<Text className='ai-card-title'>AI 健康建议</Text>
|
||
<Text className='ai-card-count'>{aiSuggestions.length} 条</Text>
|
||
</View>
|
||
{aiSuggestions.map((s) => {
|
||
const riskCls = s.risk_level === 'high' ? 'ai-risk-high' : s.risk_level === 'medium' ? 'ai-risk-medium' : 'ai-risk-low';
|
||
const params = s.params as Record<string, unknown> | null;
|
||
const reason = (params?.reason as string) || (params?.message as string) || '健康建议';
|
||
return (
|
||
<View key={s.id} className='ai-suggestion-item'>
|
||
<View className='ai-suggestion-main' onClick={() => {
|
||
if (s.suggestion_type === 'appointment') safeNavigateTo('/pages/appointment/create/index');
|
||
else if (s.suggestion_type === 'followup') safeNavigateTo('/pages/pkg-profile/followups/index');
|
||
}}>
|
||
<View className={`ai-risk-dot ${riskCls}`} />
|
||
<Text className='ai-suggestion-text'>{reason.slice(0, 50)}</Text>
|
||
</View>
|
||
<View className='ai-feedback-row'>
|
||
<View className='ai-feedback-btn ai-feedback-adopt' onClick={async () => {
|
||
try { await submitSuggestionFeedback(s.id, 'adopt'); Taro.showToast({ title: '已采纳', icon: 'success' }); fetchData(); }
|
||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||
}}>
|
||
<Text className='ai-feedback-btn-text'>采纳</Text>
|
||
</View>
|
||
<View className='ai-feedback-btn ai-feedback-ignore' onClick={async () => {
|
||
try { await submitSuggestionFeedback(s.id, 'ignore'); Taro.showToast({ title: '已忽略', icon: 'success' }); fetchData(); }
|
||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||
}}>
|
||
<Text className='ai-feedback-btn-text'>忽略</Text>
|
||
</View>
|
||
<View className='ai-feedback-btn ai-feedback-consult' onClick={async () => {
|
||
try { await submitSuggestionFeedback(s.id, 'consult'); safeNavigateTo('/pages/consultation/index'); }
|
||
catch { Taro.showToast({ title: '操作失败', icon: 'none' }); }
|
||
}}>
|
||
<Text className='ai-feedback-btn-text'>咨询医生</Text>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
)}
|
||
|
||
{/* 7天趋势 */}
|
||
<View className='trend-section'>
|
||
<Text className='section-title'>近 7 天趋势</Text>
|
||
<SegmentTabs tabs={VITAL_TABS} activeKey={activeTab} onChange={handleTabChange} variant="pill" />
|
||
{trendLoading ? <Loading /> : trendData.length === 0 ? (
|
||
<ContentCard padding="md">
|
||
<Text className='trend-empty-text'>暂无趋势数据</Text>
|
||
</ContentCard>
|
||
) : (
|
||
<ContentCard padding="md">
|
||
<View className='trend-bars'>
|
||
{(() => {
|
||
const tv = getThresholdValue(activeTab);
|
||
if (tv) {
|
||
const pct = Math.min(95, (tv / maxTrendValue) * 100);
|
||
return (
|
||
<View className='trend-threshold-line' style={`bottom:${((12 + pct * 1.08) / 120 * 100).toFixed(1)}%;`}>
|
||
<Text className='trend-threshold-label'>{tv}</Text>
|
||
</View>
|
||
);
|
||
}
|
||
return null;
|
||
})()}
|
||
{trendData.map((point, i) => {
|
||
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
|
||
const tv = getThresholdValue(activeTab);
|
||
const isAbnormal = tv ? point.value >= tv : false;
|
||
const dayOfWeek = new Date(point.date).getDay();
|
||
return (
|
||
<View className='trend-bar-col' key={i}>
|
||
<View
|
||
className={`trend-bar ${isAbnormal ? 'trend-bar-warn' : 'trend-bar-normal'}`}
|
||
style={`height:${heightPct}%;`}
|
||
/>
|
||
<Text className='trend-bar-label'>{dayLabels[dayOfWeek]}</Text>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</ContentCard>
|
||
)}
|
||
</View>
|
||
|
||
{/* 健康资讯入口 */}
|
||
<ContentCard onPress={() => safeNavigateTo('/pages/article/index')}>
|
||
<Text className='article-entry-text'>最新健康资讯 ›</Text>
|
||
</ContentCard>
|
||
</PageShell>
|
||
);
|
||
}
|