Files
hms/apps/miniprogram/src/pages/health/index.tsx
iven 1e59007bd5 fix(mp): DevTools 卡死 + 主包 2MB→766KB + 代码质量 4 项全通过
根因:主包 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
2026-05-24 11:32:40 +08:00

245 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}