Files
hms/apps/miniprogram/src/pages/health/index.tsx
iven f4b536accb
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
fix(miniprogram): AI 建议卡片跳转修复 — 按建议类型跳转对应页面
2026-05-01 18:17:46 +08:00

374 lines
14 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 } from '@tarojs/taro';
import { useHealthStore } from '../../stores/health';
import { useAuthStore } from '../../stores/auth';
import { inputVitalSign, getTrend } from '../../services/health';
import { listPendingSuggestions, type AiSuggestionItem } from '../../services/ai-analysis';
import Loading from '../../components/Loading';
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: '体重' },
];
const REF_RANGES: Record<VitalType, { range: string; warn: string }> = {
blood_pressure: { range: '收缩压 90-140 / 舒张压 60-90 mmHg', warn: '血压偏高,确认提交?' },
heart_rate: { range: '60-100 bpm', warn: '心率异常,确认提交?' },
blood_sugar: { range: '空腹 3.9-6.1 / 餐后 <7.8 mmol/L', warn: '血糖偏高,确认提交?' },
weight: { range: '根据 BMI 18.5-24 计算', warn: '' },
};
interface TrendPoint {
date: string;
value: number;
}
export default function Health() {
const { todaySummary, loading, refreshToday, getTrend: fetchTrend } = useHealthStore();
const { currentPatient } = useAuthStore();
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[]>([]);
useDidShow(() => {
refreshToday();
loadTrend(activeTab);
loadAiSuggestions();
});
const loadAiSuggestions = async () => {
try {
const items = await listPendingSuggestions();
setAiSuggestions(items.slice(0, 3));
} catch {
// 静默
}
};
const loadTrend = async (type: VitalType) => {
setTrendLoading(true);
try {
const indicatorMap: Record<VitalType, string> = {
blood_pressure: 'blood_pressure_systolic',
heart_rate: 'heart_rate',
blood_sugar: 'blood_sugar_fasting',
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);
if (sys > 140 || dia > 90) return REF_RANGES.blood_pressure.warn;
} else if (type === 'heart_rate') {
const val = parseFloat(heartRateVal);
if (val > 100 || val < 60) return REF_RANGES.heart_rate.warn;
} else if (type === 'blood_sugar') {
const val = parseFloat(sugarVal);
if (sugarPeriod === 'fasting' && val > 6.1) return REF_RANGES.blood_sugar.warn;
if (sugarPeriod === 'postprandial' && val > 7.8) return REF_RANGES.blood_sugar.warn;
}
return null;
};
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; }
await inputVitalSign(patientId, { indicator_type: 'blood_sugar', 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 dayLabels = ['日', '一', '二', '三', '四', '五', '六'];
return (
<View className='health-page'>
{/* 页头 */}
<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/pkg-appointment/create/index?patientId=${first.patient_id}` });
} 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 riskColor = s.risk_level === 'high' ? '#ef4444' : s.risk_level === 'medium' ? '#f59e0b' : '#22c55e';
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' style={{ background: riskColor }} />
<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' style='margin-top:20px;'></Text>
<Input
className='input-field'
type='number'
placeholder='如 85'
value={diastolic}
onInput={(e) => setDiastolic(e.detail.value)}
/>
<Text className='input-ref'>{REF_RANGES.blood_pressure.range}</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'>{REF_RANGES.heart_rate.range}</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'>{REF_RANGES.blood_sugar.range}</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'>{REF_RANGES.weight.range}</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'>
{trendData.map((point, i) => {
const heightPct = Math.max(8, (point.value / maxTrendValue) * 100);
const isAbnormal = activeTab === 'blood_pressure' ? point.value > 140
: activeTab === 'heart_rate' ? (point.value > 100 || point.value < 60)
: activeTab === 'blood_sugar' ? point.value > 6.1
: 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='device-section'>
<View
className='device-card'
onClick={() => Taro.navigateTo({ url: '/pages/device-sync/index' })}
>
<View className='device-icon'>
<Text className='device-icon-text'></Text>
</View>
<View className='device-info'>
<Text className='device-name'></Text>
<Text className='device-desc'></Text>
</View>
<Text className='device-arrow'></Text>
</View>
</View>
{/* 健康资讯入口 */}
<View
className='article-entry'
onClick={() => Taro.navigateTo({ url: '/pages/article/index' })}
>
<Text className='article-entry-text'> </Text>
</View>
</View>
);
}