- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片 - 记录页面每个指标独立日期+保存,紧凑设计 - 趋势图支持多指标切换显示,同时显示收缩压和舒张压 - 首页健康概览修复返回页面后数据不更新的问题 - Vite 添加代理,支持手机通过局域网 IP 访问 - 医生端患者详情新增健康趋势图及指标切换 - 运动饮食页面支持删除记录 - 修复复查完成后患者端消失的问题
309 lines
13 KiB
TypeScript
309 lines
13 KiB
TypeScript
import { useEffect, useState, useMemo } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import { api } from '../../services/api-client';
|
|
import { MultiLineChart, type SeriesData } from '../../components/charts/MultiLineChart';
|
|
|
|
interface PatientDetail {
|
|
id: string; name: string; phone: string; gender: string; birthday: string;
|
|
heightCm: number; weightKg: number; medicalHistory: string[];
|
|
stentDate: string; stentType: string;
|
|
}
|
|
|
|
interface HealthRecord {
|
|
id: string; type: string; value: string; unit: string; recordedAt: string;
|
|
}
|
|
|
|
interface ExerciseEntry {
|
|
type: string; duration: number; intensity: string; caloriesBurned: number; date: string;
|
|
}
|
|
|
|
interface DietEntry {
|
|
foods: { name: string; amount?: string; calories?: number }[];
|
|
mealType: string; totalCalories: number; date: string;
|
|
}
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
blood_pressure: '血压', heart_rate: '心率', blood_sugar: '血糖', spo2: '血氧',
|
|
};
|
|
|
|
const typeColors: Record<string, string> = {
|
|
blood_pressure: '#EF4444', heart_rate: '#F59E0B', blood_sugar: '#4F6EF7', spo2: '#20C997',
|
|
};
|
|
|
|
const typeBgs: Record<string, string> = {
|
|
blood_pressure: '#FEE9E9', heart_rate: '#FFF8E6', blood_sugar: '#EDF0FD', spo2: '#E6F9F2',
|
|
};
|
|
|
|
const exerciseIcons: Record<string, string> = {
|
|
'散步': '🚶', '慢跑': '🏃', '太极拳': '🤸', '游泳': '🏊', '骑自行车': '🚴', '八段锦': '🧘',
|
|
};
|
|
|
|
const mealLabels: Record<string, string> = {
|
|
breakfast: '早餐', lunch: '午餐', dinner: '晚餐', snack: '加餐',
|
|
};
|
|
const mealIcons: Record<string, string> = {
|
|
breakfast: '🌅', lunch: '☀️', dinner: '🌙', snack: '🍎',
|
|
};
|
|
|
|
export function PatientDetailPage() {
|
|
const { id } = useParams<{ id: string }>();
|
|
const [patient, setPatient] = useState<PatientDetail | null>(null);
|
|
const [records, setRecords] = useState<HealthRecord[]>([]);
|
|
const [exercises, setExercises] = useState<ExerciseEntry[]>([]);
|
|
const [diets, setDiets] = useState<DietEntry[]>([]);
|
|
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
api.get<PatientDetail>(`/api/patients/${id}`).then((r) => {
|
|
if (r.data) setPatient(r.data);
|
|
}).catch(() => {});
|
|
api.get<HealthRecord[]>(`/api/health-records?patientId=${id}&days=30`).then((r) => {
|
|
const all = r.data || [];
|
|
setRecords(all.filter((x) => ['blood_pressure', 'heart_rate', 'blood_sugar', 'spo2'].includes(x.type)));
|
|
|
|
// Parse exercise records
|
|
const exList: ExerciseEntry[] = [];
|
|
const dietList: DietEntry[] = [];
|
|
all.filter((x) => x.type === 'exercise' || x.type === 'diet').forEach((r) => {
|
|
try {
|
|
const v = JSON.parse(r.value);
|
|
const date = r.recordedAt?.split('T')[0] || '';
|
|
if (r.type === 'exercise') {
|
|
exList.push({ type: v.type, duration: v.duration, intensity: v.intensity, caloriesBurned: v.caloriesBurned || v.calories, date });
|
|
} else {
|
|
dietList.push({ mealType: v.mealType || v.meal, foods: v.foods || [], totalCalories: v.totalCalories, date });
|
|
}
|
|
} catch { /* skip */ }
|
|
});
|
|
exList.sort((a, b) => b.date.localeCompare(a.date));
|
|
dietList.sort((a, b) => b.date.localeCompare(a.date));
|
|
setExercises(exList.slice(0, 10));
|
|
setDiets(dietList.slice(0, 10));
|
|
}).catch(() => {});
|
|
}, [id]);
|
|
|
|
if (!patient) return <div style={{ padding: 28, color: '#9BA0B4' }}>加载中...</div>;
|
|
|
|
const latestByType: Record<string, HealthRecord> = {};
|
|
records.forEach((r) => {
|
|
if (!latestByType[r.type] || r.recordedAt > latestByType[r.type].recordedAt) {
|
|
latestByType[r.type] = r;
|
|
}
|
|
});
|
|
|
|
const parseValueDisplay = (r: HealthRecord) => {
|
|
try {
|
|
const v = JSON.parse(r.value);
|
|
if (typeof v === 'object') return `${v.systolic ?? v.value ?? '-'}/${v.diastolic ?? '-'}`;
|
|
return String(v.value ?? v);
|
|
} catch { return r.value; }
|
|
};
|
|
|
|
return (
|
|
<div style={{ padding: 28, maxWidth: 1100 }}>
|
|
<Link to="/patients" style={{ fontSize: 13, color: '#4F6EF7', fontWeight: 500 }}>← 返回患者列表</Link>
|
|
|
|
{/* Patient info card */}
|
|
<div style={{ background: '#fff', marginTop: 16, padding: 28, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 20 }}>
|
|
<div style={{
|
|
width: 52, height: 52, borderRadius: 16,
|
|
background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: 20, fontWeight: 700, color: '#fff',
|
|
}}>
|
|
{patient.name?.charAt(0) || '?'}
|
|
</div>
|
|
<div>
|
|
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{patient.name}</h2>
|
|
<p style={{ margin: '4px 0 0', fontSize: 12, color: '#9BA0B4' }}>{patient.phone}</p>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px 32px', fontSize: 13 }}>
|
|
<InfoRow label="手机号" value={patient.phone} />
|
|
<InfoRow label="性别" value={patient.gender || '-'} />
|
|
<InfoRow label="出生日期" value={patient.birthday || '-'} />
|
|
<InfoRow label="身高/体重" value={`${patient.heightCm}cm / ${patient.weightKg}kg`} />
|
|
<InfoRow label="病史" value={(patient.medicalHistory || []).join('、') || '-'} />
|
|
<InfoRow label="支架日期" value={patient.stentDate || '-'} />
|
|
<InfoRow label="支架类型" value={patient.stentType || '-'} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Health vitals */}
|
|
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>生命体征</h3>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 14 }}>
|
|
{Object.entries(latestByType).map(([type, record]) => (
|
|
<div key={type} style={{
|
|
background: '#fff', padding: 20, borderRadius: 16,
|
|
boxShadow: '0 2px 12px rgba(0,0,0,0.04)', position: 'relative',
|
|
}}>
|
|
<div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: typeColors[type] || '#4F6EF7', borderRadius: '4px 0 0 4px' }} />
|
|
<div style={{ paddingLeft: 8 }}>
|
|
<div style={{
|
|
fontSize: 11, fontWeight: 600, color: typeColors[type] || '#4F6EF7',
|
|
background: typeBgs[type] || '#EDF0FD', display: 'inline-block',
|
|
padding: '3px 10px', borderRadius: 6, marginBottom: 10,
|
|
}}>
|
|
{typeLabels[type] || type}
|
|
</div>
|
|
<div style={{ fontSize: 22, fontWeight: 800, color: '#1A1D28' }}>
|
|
{parseValueDisplay(record)} <span style={{ fontSize: 13, fontWeight: 500, color: '#9BA0B4' }}>{record.unit}</span>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#C0C5D2', marginTop: 6 }}>
|
|
{record.recordedAt?.split('T')[0]}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Trend chart */}
|
|
<ChartSection records={records} />
|
|
|
|
{/* Exercise + Diet side by side */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginTop: 28 }}>
|
|
{/* Exercise */}
|
|
<div>
|
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
|
🏃 运动记录
|
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
|
近7天 · {exercises.reduce((s, e) => s + (e.duration || 0), 0)}分钟
|
|
</span>
|
|
</h3>
|
|
{exercises.length === 0 ? (
|
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无运动记录</div>
|
|
) : (
|
|
exercises.slice(0, 7).map((e, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
|
borderLeft: `4px solid ${e.intensity === '高' || e.intensity === 'high' ? '#EF4444' : e.intensity === '中' || e.intensity === 'moderate' ? '#F59E0B' : '#20C997'}`,
|
|
}}>
|
|
<span style={{ fontSize: 22 }}>{exerciseIcons[e.type] || '💪'}</span>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>{e.type}</div>
|
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>{e.duration}分钟 · {e.caloriesBurned}kcal</div>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{e.date?.slice(5)}</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* Diet */}
|
|
<div>
|
|
<h3 style={{ margin: '0 0 14px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>
|
|
🥗 饮食记录
|
|
<span style={{ fontSize: 12, fontWeight: 500, color: '#9BA0B4', marginLeft: 8 }}>
|
|
近7天 · {diets.reduce((s, d) => s + (d.totalCalories || 0), 0)}kcal
|
|
</span>
|
|
</h3>
|
|
{diets.length === 0 ? (
|
|
<div style={{ padding: 40, textAlign: 'center', color: '#C0C5D2', background: '#fff', borderRadius: 14, fontSize: 13 }}>暂无饮食记录</div>
|
|
) : (
|
|
diets.slice(0, 7).map((d, i) => (
|
|
<div key={i} style={{
|
|
display: 'flex', alignItems: 'center', gap: 12, padding: '12px 16px',
|
|
background: '#fff', borderRadius: 12, marginBottom: 8,
|
|
boxShadow: '0 1px 6px rgba(0,0,0,0.03)',
|
|
borderLeft: '4px solid #20C997',
|
|
}}>
|
|
<span style={{ fontSize: 22 }}>{mealIcons[d.mealType] || '🍽️'}</span>
|
|
<div style={{ flex: 1 }}>
|
|
<div style={{ fontSize: 14, fontWeight: 600, color: '#1A1D28' }}>
|
|
{d.foods?.map(f => f.name).join('、') || '-'}
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#9BA0B4' }}>
|
|
{mealLabels[d.mealType] || d.mealType} · {d.totalCalories}kcal
|
|
</div>
|
|
</div>
|
|
<div style={{ fontSize: 11, color: '#C0C5D2' }}>{d.date?.slice(5)}</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CHART_INDICATORS = [
|
|
{ type: 'bp_sys', label: '收缩压', color: '#DC4A4A', unit: 'mmHg', source: 'blood_pressure', field: 'systolic' as const },
|
|
{ type: 'bp_dia', label: '舒张压', color: '#E0558A', unit: 'mmHg', source: 'blood_pressure', field: 'diastolic' as const },
|
|
{ type: 'heart_rate', label: '心率', color: '#D68B20', unit: 'bpm' },
|
|
{ type: 'blood_sugar', label: '血糖', color: '#7C5CE7', unit: 'mmol/L' },
|
|
{ type: 'spo2', label: '血氧', color: '#3B8ED4', unit: '%' },
|
|
{ type: 'weight', label: '体重', color: '#3DAF86', unit: 'kg' },
|
|
];
|
|
|
|
function ChartSection({ records }: { records: HealthRecord[] }) {
|
|
const [visible, setVisible] = useState<Set<string>>(new Set(CHART_INDICATORS.map((i) => i.type)));
|
|
|
|
const series: SeriesData[] = useMemo(() => {
|
|
return CHART_INDICATORS
|
|
.filter((ind) => visible.has(ind.type))
|
|
.map((ind) => {
|
|
const source = (ind as Record<string, string>).source || ind.type;
|
|
const field = (ind as Record<string, string>).field;
|
|
const raw = records
|
|
.filter((r) => r.type === source)
|
|
.sort((a, b) => a.recordedAt.localeCompare(b.recordedAt));
|
|
|
|
const data = raw.map((r) => {
|
|
try {
|
|
const v = JSON.parse(r.value);
|
|
const val = field ? (v[field] ?? 0) : (v.value ?? v);
|
|
return { date: r.recordedAt.split('T')[0], value: Number(val) || 0 };
|
|
} catch { return { date: r.recordedAt.split('T')[0], value: 0 }; }
|
|
});
|
|
return { name: ind.label, color: ind.color, data, unit: ind.unit };
|
|
});
|
|
}, [records, visible]);
|
|
|
|
const toggle = (type: string) => {
|
|
const next = new Set(visible);
|
|
if (next.has(type)) next.delete(type); else next.add(type);
|
|
setVisible(next);
|
|
};
|
|
|
|
const hasData = series.some((s) => s.data.length > 0);
|
|
if (!hasData) return null;
|
|
|
|
return (
|
|
<div style={{ marginTop: 28 }}>
|
|
<h3 style={{ margin: '0 0 10px', fontSize: 17, fontWeight: 700, color: '#1A1D28' }}>📈 健康趋势</h3>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
|
{CHART_INDICATORS.map((ind) => (
|
|
<button
|
|
key={ind.type}
|
|
onClick={() => toggle(ind.type)}
|
|
style={{
|
|
padding: '5px 12px', borderRadius: 16, border: '1.5px solid',
|
|
borderColor: ind.color, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
|
...(visible.has(ind.type)
|
|
? { background: ind.color, color: '#fff' }
|
|
: { background: '#fff', color: ind.color }),
|
|
}}
|
|
>
|
|
{ind.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div style={{ background: '#fff', padding: 16, borderRadius: 16, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
<MultiLineChart series={series} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
|
|
<span style={{ color: '#9BA0B4' }}>{label}</span>
|
|
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|