Files
soft/frontend-doctor/src/pages/patients/PatientDetailPage.tsx
MingNian f412a474cd 重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图
- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片
- 记录页面每个指标独立日期+保存,紧凑设计
- 趋势图支持多指标切换显示,同时显示收缩压和舒张压
- 首页健康概览修复返回页面后数据不更新的问题
- Vite 添加代理,支持手机通过局域网 IP 访问
- 医生端患者详情新增健康趋势图及指标切换
- 运动饮食页面支持删除记录
- 修复复查完成后患者端消失的问题
2026-05-26 15:56:06 +08:00

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>
);
}