feat(web): 趋势图多指标展示 — 5项体征指标迷你趋势卡片网格
收缩压/舒张压/心率/体重/血糖各一个迷你 Line 图, 并行加载、无数据指标显示空状态,录入后自动刷新
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Line } from '@ant-design/charts';
|
import { Line } from '@ant-design/charts';
|
||||||
import { Spin, Alert, Typography } from 'antd';
|
import { Spin, Typography } from 'antd';
|
||||||
import { LineChartOutlined } from '@ant-design/icons';
|
import { LineChartOutlined } from '@ant-design/icons';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
|
|
||||||
@@ -11,28 +11,111 @@ interface Props {
|
|||||||
refreshKey?: number;
|
refreshKey?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_INDICATOR = 'systolic_bp_morning';
|
interface MetricConfig {
|
||||||
const UNIT = 'mmHg';
|
key: string;
|
||||||
const LABEL = '收缩压(晨)';
|
label: string;
|
||||||
|
unit: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRICS: MetricConfig[] = [
|
||||||
|
{ key: 'systolic_bp_morning', label: '收缩压(晨)', unit: 'mmHg', color: '#ef4444' },
|
||||||
|
{ key: 'diastolic_bp_morning', label: '舒张压(晨)', unit: 'mmHg', color: '#f97316' },
|
||||||
|
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#3b82f6' },
|
||||||
|
{ key: 'weight', label: '体重', unit: 'kg', color: '#10b981' },
|
||||||
|
{ key: 'blood_sugar', label: '血糖', unit: 'mmol/L', color: '#8b5cf6' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface MetricData {
|
||||||
|
points: { date: string; value: number }[];
|
||||||
|
latest: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractData(res: unknown): { date: string; value: number }[] {
|
||||||
|
const raw = (res as { data?: { date: string; value: number }[] })?.data;
|
||||||
|
const arr = Array.isArray(res) ? res : Array.isArray(raw) ? raw : [];
|
||||||
|
return arr.filter((d) => d?.value != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单个指标迷你卡片 */
|
||||||
|
function MetricCard({ metric, metricData }: { metric: MetricConfig; metricData: MetricData }) {
|
||||||
|
if (metricData.points.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--ant-color-bg-container, #fafafa)',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
}}>
|
||||||
|
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf' }} />
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>暂无{metric.label}数据</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: 'var(--ant-color-bg-container, #fafafa)',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||||||
|
<Text style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)' }}>{metric.label}</Text>
|
||||||
|
{metricData.latest != null && (
|
||||||
|
<Text style={{ fontSize: 12, fontWeight: 600, color: metric.color }}>
|
||||||
|
{metricData.latest} <span style={{ fontWeight: 400, fontSize: 10 }}>{metric.unit}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Line
|
||||||
|
data={metricData.points}
|
||||||
|
xField="date"
|
||||||
|
yField="value"
|
||||||
|
smooth
|
||||||
|
height={60}
|
||||||
|
axis={false}
|
||||||
|
point={{ shapeField: 'circle', sizeField: 2 }}
|
||||||
|
style={{ lineWidth: 1.5, stroke: metric.color }}
|
||||||
|
tooltip={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
||||||
const [data, setData] = useState<{ date: string; value: number }[]>([]);
|
const [metricsData, setMetricsData] = useState<Record<string, MetricData>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!patientId) return;
|
if (!patientId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
|
||||||
healthDataApi
|
Promise.allSettled(
|
||||||
.getIndicatorTimeseries(patientId, DEFAULT_INDICATOR)
|
METRICS.map((m) =>
|
||||||
.then((res) => {
|
healthDataApi
|
||||||
const raw = (res as { data?: { date: string; value: number }[] })?.data;
|
.getIndicatorTimeseries(patientId, m.key)
|
||||||
const points = Array.isArray(res) ? res : Array.isArray(raw) ? raw : [];
|
.then(extractData)
|
||||||
setData(points.filter((d) => d?.value != null));
|
.then((points) => ({
|
||||||
})
|
key: m.key,
|
||||||
.catch(() => setError(true))
|
data: { points, latest: points.length > 0 ? points[points.length - 1].value : null },
|
||||||
.finally(() => setLoading(false));
|
}))
|
||||||
|
)
|
||||||
|
).then((results) => {
|
||||||
|
const map: Record<string, MetricData> = {};
|
||||||
|
results.forEach((r, i) => {
|
||||||
|
if (r.status === 'fulfilled') {
|
||||||
|
map[r.value.key] = r.value.data;
|
||||||
|
} else {
|
||||||
|
map[METRICS[i].key] = { points: [], latest: null };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setMetricsData(map);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
}, [patientId, refreshKey]);
|
}, [patientId, refreshKey]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -43,11 +126,8 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
const hasAnyData = Object.values(metricsData).some((d) => d.points.length > 0);
|
||||||
return <Alert type="error" message="加载趋势数据失败" showIcon style={{ borderRadius: 8 }} />;
|
if (!hasAnyData) {
|
||||||
}
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -59,36 +139,20 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
border: '1px dashed var(--ant-color-border, #d9d9d9)',
|
border: '1px dashed var(--ant-color-border, #d9d9d9)',
|
||||||
}}>
|
}}>
|
||||||
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf', marginRight: 6 }} />
|
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf', marginRight: 6 }} />
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>暂无{LABEL}趋势数据</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>暂无趋势数据</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const latestValue = data[data.length - 1].value;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
display: 'grid',
|
||||||
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{LABEL}趋势</Text>
|
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||||
{latestValue != null && (
|
gap: 8,
|
||||||
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>
|
}}>
|
||||||
最新:{latestValue} {UNIT}
|
{METRICS.map((m) => (
|
||||||
</Text>
|
<MetricCard key={m.key} metric={m} metricData={metricsData[m.key] ?? { points: [], latest: null }} />
|
||||||
)}
|
))}
|
||||||
</div>
|
|
||||||
<Line
|
|
||||||
data={data}
|
|
||||||
xField="date"
|
|
||||||
yField="value"
|
|
||||||
smooth
|
|
||||||
height={140}
|
|
||||||
point={{ shapeField: 'circle', sizeField: 3 }}
|
|
||||||
axis={{
|
|
||||||
x: { labelAutoRotate: false },
|
|
||||||
y: { title: UNIT },
|
|
||||||
}}
|
|
||||||
style={{ lineWidth: 2 }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user