- 新增 $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)
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
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>
|
||
);
|
||
}
|