重构健康中心页面:合并指标入口、独立记录页面、多指标趋势图
- 健康中心合并血压/心率/血糖/血氧/体重为统一入口卡片 - 记录页面每个指标独立日期+保存,紧凑设计 - 趋势图支持多指标切换显示,同时显示收缩压和舒张压 - 首页健康概览修复返回页面后数据不更新的问题 - Vite 添加代理,支持手机通过局域网 IP 访问 - 医生端患者详情新增健康趋势图及指标切换 - 运动饮食页面支持删除记录 - 修复复查完成后患者端消失的问题
This commit is contained in:
63
frontend-doctor/src/components/charts/MultiLineChart.tsx
Normal file
63
frontend-doctor/src/components/charts/MultiLineChart.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user