根因:主包 2MB 全量组件注入导致 DevTools 渲染引擎内存渐增, 叠加离线时固定 3s 抑制期后的请求洪泛。 修复: - app.config.ts 添加 lazyCodeLoading: requiredComponents 主包 2.0MB→766KB,taro.js 526→131KB,vendors.js 230→28KB - request.ts 离线抑制改为指数退避(3s→6s→12s→30s cap) 后端不可达时自动延长抑制,防止请求风暴 - SegmentTabs Tab 接口改为 readonly,修复 TS 编译错误 - AbortController polyfill 补齐小程序运行时缺失 - 健康首页/设备同步/健康档案/报告/设置页 UI 重构 - 文章页公开端点适配游客访问 - 健康首页 Swiper 间隔优化 4s→5s,动画 500→300ms
245 lines
10 KiB
TypeScript
245 lines
10 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: '✏', color: 'input', path: '/pages/pkg-health/input/index' },
|
||
{ label: '健康趋势', icon: '📈', color: 'trend', path: '/pages/pkg-health/trend/index' },
|
||
{ label: '我的报告', icon: '📋', color: 'report', path: '/pages/pkg-profile/reports/index' },
|
||
{ label: '健康档案', icon: '健', color: 'med', path: '/pages/pkg-profile/health-records/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';
|
||
}
|
||
|
||
function formatDate(): string {
|
||
const d = new Date();
|
||
const month = d.getMonth() + 1;
|
||
const day = d.getDate();
|
||
const weekDays = ['日', '一', '二', '三', '四', '五', '六'];
|
||
return `${month}月${day}日 周${weekDays[d.getDay()]}`;
|
||
}
|
||
|
||
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 recordedCount = vitals.filter((v) => v.value !== '—').length;
|
||
|
||
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>
|
||
<Text className='health-date'>{formatDate()}</Text>
|
||
</View>
|
||
|
||
{/* 今日体征 hero 卡片 */}
|
||
<View className='vitals-grid'>
|
||
<View className='vitals-header'>
|
||
<Text className='vitals-title'>今日体征</Text>
|
||
{recordedCount > 0 && (
|
||
<Text className='vitals-badge'>已记录 {recordedCount} 项</Text>
|
||
)}
|
||
</View>
|
||
{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>
|
||
)}
|
||
</View>
|
||
|
||
{/* 快捷入口 — 横排 4 格图标 */}
|
||
<View className='quick-entries'>
|
||
{QUICK_ENTRIES.map((e) => (
|
||
<View
|
||
key={e.label}
|
||
className='quick-entry'
|
||
onClick={() => safeNavigateTo(e.path)}
|
||
>
|
||
<View className={`quick-icon quick-icon--${e.color}`}>
|
||
<Text className='quick-icon-text'>{e.icon}</Text>
|
||
</View>
|
||
<Text className='quick-label'>{e.label}</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
|
||
{/* 告警横幅 */}
|
||
{alertCount > 0 && (
|
||
<ContentCard
|
||
variant="default"
|
||
padding="sm"
|
||
margin="none"
|
||
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>
|
||
);
|
||
}
|