feat(web): 趋势图多指标展示 — 5项体征指标迷你趋势卡片网格
收缩压/舒张压/心率/体重/血糖各一个迷你 Line 图, 并行加载、无数据指标显示空状态,录入后自动刷新
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Line } from '@ant-design/charts';
|
||||
import { Spin, Alert, Typography } from 'antd';
|
||||
import { Spin, Typography } from 'antd';
|
||||
import { LineChartOutlined } from '@ant-design/icons';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
|
||||
@@ -11,28 +11,111 @@ interface Props {
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_INDICATOR = 'systolic_bp_morning';
|
||||
const UNIT = 'mmHg';
|
||||
const LABEL = '收缩压(晨)';
|
||||
interface MetricConfig {
|
||||
key: string;
|
||||
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) {
|
||||
const [data, setData] = useState<{ date: string; value: number }[]>([]);
|
||||
const [metricsData, setMetricsData] = useState<Record<string, MetricData>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!patientId) return;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
healthDataApi
|
||||
.getIndicatorTimeseries(patientId, DEFAULT_INDICATOR)
|
||||
.then((res) => {
|
||||
const raw = (res as { data?: { date: string; value: number }[] })?.data;
|
||||
const points = Array.isArray(res) ? res : Array.isArray(raw) ? raw : [];
|
||||
setData(points.filter((d) => d?.value != null));
|
||||
})
|
||||
.catch(() => setError(true))
|
||||
.finally(() => setLoading(false));
|
||||
|
||||
Promise.allSettled(
|
||||
METRICS.map((m) =>
|
||||
healthDataApi
|
||||
.getIndicatorTimeseries(patientId, m.key)
|
||||
.then(extractData)
|
||||
.then((points) => ({
|
||||
key: m.key,
|
||||
data: { points, latest: points.length > 0 ? points[points.length - 1].value : null },
|
||||
}))
|
||||
)
|
||||
).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]);
|
||||
|
||||
if (loading) {
|
||||
@@ -43,11 +126,8 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert type="error" message="加载趋势数据失败" showIcon style={{ borderRadius: 8 }} />;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
const hasAnyData = Object.values(metricsData).some((d) => d.points.length > 0);
|
||||
if (!hasAnyData) {
|
||||
return (
|
||||
<div style={{
|
||||
height: 48,
|
||||
@@ -59,36 +139,20 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
||||
border: '1px dashed var(--ant-color-border, #d9d9d9)',
|
||||
}}>
|
||||
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf', marginRight: 6 }} />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>暂无{LABEL}趋势数据</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>暂无趋势数据</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const latestValue = data[data.length - 1].value;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{LABEL}趋势</Text>
|
||||
{latestValue != null && (
|
||||
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>
|
||||
最新:{latestValue} {UNIT}
|
||||
</Text>
|
||||
)}
|
||||
</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 style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
||||
gap: 8,
|
||||
}}>
|
||||
{METRICS.map((m) => (
|
||||
<MetricCard key={m.key} metric={m} metricData={metricsData[m.key] ?? { points: [], latest: null }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user