重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图

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

View File

@@ -0,0 +1,63 @@
import ReactECharts from 'echarts-for-react';
export interface SeriesData {
name: string;
color: string;
data: { date: string; value: number }[];
unit: string;
}
interface MultiLineChartProps {
series: SeriesData[];
}
export function MultiLineChart({ series }: MultiLineChartProps) {
if (series.length === 0) return null;
const allDates = [...new Set(series.flatMap((s) => s.data.map((d) => d.date)))].sort();
const option = {
grid: { top: 16, right: 24, bottom: 24, left: 48 },
tooltip: {
trigger: 'axis',
formatter: (params: unknown[]) => {
const items = params as { axisValue: string; color: string; seriesName: string; data: number }[];
let html = `<div style="font-weight:600;margin-bottom:4px">${items[0]?.axisValue || ''}</div>`;
items.forEach((p) => {
const s = series.find((x) => x.name === p.seriesName);
html += `<div><span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${p.color};margin-right:6px"></span>${p.seriesName}: ${p.data} ${s?.unit || ''}</div>`;
});
return html;
},
},
xAxis: {
type: 'category',
data: allDates.map((d) => d.slice(5)),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { fontSize: 10, color: '#9CA3AF' },
},
yAxis: series.map((s, i) => ({
type: 'value',
name: i === 0 ? undefined : '',
splitLine: i === 0 ? { lineStyle: { color: '#F3F4F6' } } : { show: false },
axisLabel: { fontSize: 10, color: i === 0 ? '#9CA3AF' : 'transparent' },
})),
series: series.map((s, i) => ({
name: s.name,
type: 'line',
data: allDates.map((date) => {
const match = s.data.find((d) => d.date === date);
return match ? match.value : null;
}),
smooth: true,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: s.color, width: 2 },
itemStyle: { color: s.color },
yAxisIndex: i,
})),
};
return <ReactECharts option={option} style={{ height: 320 }} notMerge />;
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
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;
@@ -12,6 +13,15 @@ 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: '血氧',
};
@@ -24,17 +34,52 @@ 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) => setRecords(r.data));
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>;
@@ -55,9 +100,10 @@ export function PatientDetailPage() {
};
return (
<div style={{ padding: 28 }}>
<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={{
@@ -73,40 +119,19 @@ export function PatientDetailPage() {
<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 }}>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.phone}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.gender || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.birthday || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}>/</span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.heightCm}cm / {patient.weightKg}kg</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{(patient.medicalHistory || []).join('、') || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentDate || '-'}</span>
</div>
<div style={{ padding: '8px 0', borderBottom: '1px solid #F5F6F9' }}>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ marginLeft: 16, color: '#1A1D28' }}>{patient.stentType || '-'}</span>
</div>
<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>
<h3 style={{ marginTop: 28, marginBottom: 14, fontSize: 17, fontWeight: 700, color: '#1A1D28' }}></h3>
{/* 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={{
@@ -132,6 +157,152 @@ export function PatientDetailPage() {
</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>
);
}