Files
hms/apps/miniprogram/src/pages/health/index.tsx
iven 93c77c5857 fix(mp): T40 UI 设计系统合规审计修复 — 60 页面全覆盖
- 新增 $white 语义变量 + --tk-font-display Token
- 44 处 #fff → $white,2 处 background: #fff → $card
- 14 处 border-radius 硬编码统一为 $r-xs/$r-lg/$r
- 3 处 TSX inline 颜色提取为 SCSS 类(exchange/orders/action-inbox)
- ErrorBoundary 重构:6 个 inline style → SCSS 类 + Design Token
- 2 处离调色板颜色修正(#0284C7→$tx2, #94A3B8→$tx3)
- 2 处静默 catch 块添加状态清理(article/health)
- 趋势页补 Loading/EmptyState;咨询页 GuestGuard 统一
- 4 处 #FFFFFF → $white(mixins/index/exchange/variables)
2026-05-13 23:26:00 +08:00

412 lines
16 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 { useState, useEffect } from 'react';
import { View, Text, Input } from '@tarojs/components';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { useAuthStore } from '../../stores/auth';
import { useElderClass } from '../../hooks/useElderClass';
import { inputVitalSign, getTrend, getHealthThresholds, findThreshold, DEFAULT_THRESHOLDS, type HealthThreshold } from '../../services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
import Loading from '../../components/Loading';
import GuestGuard from '../../components/GuestGuard';
import './index.scss';
type VitalType = 'blood_pressure' | 'heart_rate' | 'blood_sugar' | 'weight';
const VITAL_TABS: { key: VitalType; label: string }[] = [
{ key: 'blood_pressure', label: '血压' },
{ key: 'heart_rate', label: '心率' },
{ key: 'blood_sugar', label: '血糖' },
{ key: 'weight', label: '体重' },
];
/** 根据阈值列表构建参考范围文案 */
function buildRefRange(t: HealthThreshold[]): Record<VitalType, string> {
const bpSys = findThreshold(t, 'systolic_bp', 'high')?.threshold_value ?? 140;
const bpDia = findThreshold(t, 'diastolic_bp', 'high')?.threshold_value ?? 90;
const hrHigh = findThreshold(t, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(t, 'heart_rate', 'low')?.threshold_value ?? 60;
const bsFasting = findThreshold(t, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
const bsPp = findThreshold(t, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
return {
blood_pressure: `收缩压 90-${bpSys} / 舒张压 60-${bpDia} mmHg`,
heart_rate: `${hrLow}-${hrHigh} bpm`,
blood_sugar: `空腹 3.9-${bsFasting} / 餐后 <${bsPp} mmol/L`,
weight: '根据 BMI 18.5-24 计算',
};
}
interface TrendPoint {
date: string;
value: number;
}
export default function Health() {
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
const { user, currentPatient } = useAuthStore();
const modeClass = useElderClass();
const [activeTab, setActiveTab] = useState<VitalType>('blood_pressure');
const [systolic, setSystolic] = useState('');
const [diastolic, setDiastolic] = useState('');
const [heartRateVal, setHeartRateVal] = useState('');
const [sugarVal, setSugarVal] = useState('');
const [sugarPeriod, setSugarPeriod] = useState<'fasting' | 'postprandial'>('fasting');
const [weightVal, setWeightVal] = useState('');
const [saving, setSaving] = useState(false);
const [trendData, setTrendData] = useState<TrendPoint[]>([]);
const [trendLoading, setTrendLoading] = useState(false);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestionItem[]>([]);
const [thresholds, setThresholds] = useState<HealthThreshold[]>(DEFAULT_THRESHOLDS);
useDidShow(() => {
if (!user) return;
refreshToday();
loadTrend(activeTab);
loadAiSuggestions();
getHealthThresholds().then((t) => { if (t.length > 0) setThresholds(t); });
});
usePullDownRefresh(() => {
if (!user) return;
Promise.all([refreshToday(true), loadTrend(activeTab), loadAiSuggestions()]).finally(() => {
Taro.stopPullDownRefresh();
});
});
if (!user) {
return <GuestGuard title='请先登录' desc='登录后即可记录和查看健康数据' />;
}
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
setAiSuggestions([]);
}
};
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'systolic_bp_morning',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar',
weight: 'weight',
};
const points = await fetchTrend(indicatorMap[type], '7d');
setTrendData(points);
} catch {
setTrendData([]);
} finally {
setTrendLoading(false);
}
};
const handleTabChange = (tab: VitalType) => {
setActiveTab(tab);
loadTrend(tab);
};
const getWarnStatus = (type: VitalType): string | null => {
if (type === 'blood_pressure') {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
const sysMax = findThreshold(thresholds, 'systolic_bp', 'high')?.threshold_value ?? 140;
const diaMax = findThreshold(thresholds, 'diastolic_bp', 'high')?.threshold_value ?? 90;
if (sys > sysMax || dia > diaMax) return '血压偏高,确认提交?';
} else if (type === 'heart_rate') {
const val = parseFloat(heartRateVal);
const hrHigh = findThreshold(thresholds, 'heart_rate', 'high')?.threshold_value ?? 100;
const hrLow = findThreshold(thresholds, 'heart_rate', 'low')?.threshold_value ?? 60;
if (val > hrHigh || val < hrLow) return '心率异常,确认提交?';
} else if (type === 'blood_sugar') {
const val = parseFloat(sugarVal);
if (sugarPeriod === 'fasting') {
const bsMax = findThreshold(thresholds, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
if (val > bsMax) return '血糖偏高,确认提交?';
} else {
const bsMax = findThreshold(thresholds, 'blood_sugar_postprandial', 'high')?.threshold_value ?? 7.8;
if (val > bsMax) return '血糖偏高,确认提交?';
}
}
return null;
};
const refRanges = buildRefRange(thresholds);
const handleSave = async () => {
const patientId = currentPatient?.id;
if (!patientId) {
Taro.showToast({ title: '请先登录', icon: 'none' });
return;
}
const warnMsg = getWarnStatus(activeTab);
if (warnMsg) {
const { confirm } = await Taro.showModal({
title: '异常提示',
content: warnMsg,
confirmText: '确认提交',
cancelText: '再看看',
});
if (!confirm) return;
}
setSaving(true);
try {
switch (activeTab) {
case 'blood_pressure': {
const sys = parseFloat(systolic);
const dia = parseFloat(diastolic);
if (!sys || !dia) { Taro.showToast({ title: '请填写完整', icon: 'none' }); return; }
await inputVitalSign(patientId, {
indicator_type: 'blood_pressure',
value: sys,
extra: { systolic: sys, diastolic: dia },
});
setSystolic('');
setDiastolic('');
break;
}
case 'heart_rate': {
const val = parseFloat(heartRateVal);
if (!val) { Taro.showToast({ title: '请填写心率', icon: 'none' }); return; }
await inputVitalSign(patientId, { indicator_type: 'heart_rate', value: val });
setHeartRateVal('');
break;
}
case 'blood_sugar': {
const val = parseFloat(sugarVal);
if (!val) { Taro.showToast({ title: '请填写血糖值', icon: 'none' }); return; }
const bsType = sugarPeriod === 'fasting' ? 'blood_sugar_fasting' : 'blood_sugar_postprandial';
await inputVitalSign(patientId, { indicator_type: bsType, value: val });
setSugarVal('');
break;
}
case 'weight': {
const val = parseFloat(weightVal);
if (!val) { Taro.showToast({ title: '请填写体重', icon: 'none' }); return; }
await inputVitalSign(patientId, { indicator_type: 'weight', value: val });
setWeightVal('');
break;
}
}
Taro.showToast({ title: '保存成功', icon: 'success' });
refreshToday(true);
loadTrend(activeTab);
} catch {
Taro.showToast({ title: '保存失败', icon: 'none' });
} finally {
setSaving(false);
}
};
const maxTrendValue = Math.max(...trendData.map((d) => d.value), 1);
const getThresholdValue = (type: VitalType, th: HealthThreshold[]): number | null => {
if (type === 'blood_pressure') return findThreshold(th, 'systolic_bp', 'high')?.threshold_value ?? 140;
if (type === 'heart_rate') return findThreshold(th, 'heart_rate', 'high')?.threshold_value ?? 100;
if (type === 'blood_sugar') return findThreshold(th, 'blood_sugar_fasting', 'high')?.threshold_value ?? 6.1;
return null;
};
const dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
return (
<View className={`health-page ${modeClass}`}>
{/* 页头 */}
<View className='health-header'>
<Text className='health-title'></Text>
</View>
{/* AI 建议卡片 */}
{aiSuggestions.length > 0 && (
<View className='ai-suggestion-card' onClick={() => {
const first = aiSuggestions[0];
if (first?.suggestion_type === 'appointment') {
Taro.navigateTo({ url: `/pages/appointment/create/index` });
} else if (first?.suggestion_type === 'followup') {
Taro.navigateTo({ url: '/pages/pkg-profile/followups/index' });
} else {
Taro.navigateTo({ url: '/pages/health/index' });
}
}}>
<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 typeLabel = s.suggestion_type === 'followup' ? '随访' : s.suggestion_type === 'appointment' ? '预约' : '预警';
const params = s.params as Record<string, unknown> | null;
const reason = (params?.reason as string) || (params?.message as string) || typeLabel;
return (
<View key={s.id} className='ai-suggestion-item'>
<View className={`ai-risk-dot ${riskCls}`} />
<Text className='ai-suggestion-text'>{reason.slice(0, 40)}</Text>
</View>
);
})}
</View>
)}
{/* 类型 Tab */}
<View className='vital-tabs'>
{VITAL_TABS.map((tab) => {
const hasData = tab.key === 'blood_pressure' ? !!todaySummary?.blood_pressure
: tab.key === 'heart_rate' ? !!todaySummary?.heart_rate
: tab.key === 'blood_sugar' ? !!todaySummary?.blood_sugar
: !!todaySummary?.weight;
return (
<View
key={tab.key}
className={`vital-tab ${activeTab === tab.key ? 'vital-tab-active' : ''}`}
onClick={() => handleTabChange(tab.key)}
>
<Text className='vital-tab-text'>{tab.label}</Text>
{!hasData && <View className='vital-tab-dot' />}
</View>
);
})}
</View>
{/* 录入区 */}
<View className='input-section'>
{activeTab === 'blood_pressure' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='number'
placeholder='如 130'
value={systolic}
onInput={(e) => setSystolic(e.detail.value)}
/>
<Text className='input-label input-label--secondary'></Text>
<Input
className='input-field'
type='number'
placeholder='如 85'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.blood_pressure}</Text>
</View>
)}
{activeTab === 'heart_rate' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 72'
value={heartRateVal}
onInput={(e) => setHeartRateVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.heart_rate}</Text>
</View>
)}
{activeTab === 'blood_sugar' && (
<View className='input-group'>
<Text className='input-label'></Text>
<Input
className='input-field'
type='digit'
placeholder='如 5.6'
value={sugarVal}
onInput={(e) => setSugarVal(e.detail.value)}
/>
<View className='period-group'>
<View
className={`period-btn ${sugarPeriod === 'fasting' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('fasting')}
>
<Text className='period-btn-text'></Text>
</View>
<View
className={`period-btn ${sugarPeriod === 'postprandial' ? 'period-active' : ''}`}
onClick={() => setSugarPeriod('postprandial')}
>
<Text className='period-btn-text'> 2h</Text>
</View>
</View>
<Text className='input-ref'>{refRanges.blood_sugar}</Text>
</View>
)}
{activeTab === 'weight' && (
<View className='input-group'>
<Text className='input-label'> (kg)</Text>
<Input
className='input-field'
type='digit'
placeholder='如 65.5'
value={weightVal}
onInput={(e) => setWeightVal(e.detail.value)}
/>
<Text className='input-ref'>{refRanges.weight}</Text>
</View>
)}
<View className='save-btn' onClick={handleSave}>
<Text className='save-btn-text'>{saving ? '保存中...' : '保存'}</Text>
</View>
</View>
{/* 趋势图 */}
<View className='trend-section'>
<Text className='section-title'> 7 </Text>
{trendLoading ? (
<Loading />
) : trendData.length === 0 ? (
<View className='trend-empty'>
<Text className='trend-empty-text'></Text>
</View>
) : (
<View className='trend-chart'>
<View className='trend-bars'>
{/* 阈值标线 */}
{getThresholdValue(activeTab, thresholds) && (() => {
const tv = getThresholdValue(activeTab, thresholds)!;
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>
);
})()}
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const tv = getThresholdValue(activeTab, thresholds);
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>
</View>
)}
</View>
{/* BLE 设备同步功能暂缓开放 */}
{/* 健康资讯入口 */}
<View
className='article-entry'
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}
>
<Text className='article-entry-text'> </Text>
</View>
</View>
);
}