feat(web): 多指标趋势图重设计 — 概览卡片条 + 点击展开详情图
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

交互模式: 水平概览卡片条(指标名+最新值+32px微型趋势线) →
点击展开200px详情折线图(坐标轴+tooltip+关闭按钮)
5项指标独立Y轴,解决量级差异问题(血压~120 vs 血糖~5)
This commit is contained in:
iven
2026-04-26 12:21:56 +08:00
parent 1b3caf0e69
commit 2a7c3ceeb7

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Line } from '@ant-design/charts';
import { Spin, Typography } from 'antd';
import { LineChartOutlined } from '@ant-design/icons';
import { LineChartOutlined, CloseOutlined } from '@ant-design/icons';
import { healthDataApi } from '../../../api/health/healthData';
import { useThemeMode } from '../../../hooks/useThemeMode';
const { Text } = Typography;
@@ -16,14 +17,15 @@ interface MetricConfig {
label: string;
unit: string;
color: string;
normalRange?: [number, number];
}
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: 'systolic_bp_morning', label: '收缩压(晨)', unit: 'mmHg', color: '#ef4444', normalRange: [90, 140] },
{ key: 'diastolic_bp_morning', label: '舒张压(晨)', unit: 'mmHg', color: '#f97316', normalRange: [60, 90] },
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#3b82f6', normalRange: [60, 100] },
{ key: 'weight', label: '体重', unit: 'kg', color: '#10b981' },
{ key: 'blood_sugar', label: '血糖', unit: 'mmol/L', color: '#8b5cf6' },
{ key: 'blood_sugar', label: '血糖', unit: 'mmol/L', color: '#8b5cf6', normalRange: [3.9, 6.1] },
];
interface MetricData {
@@ -37,51 +39,63 @@ function extractData(res: unknown): { date: string; value: number }[] {
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>
);
}
const emptyData: MetricData = { points: [], latest: null };
/** 概览卡片 — 指标名 + 最新值 + 微型趋势线 */
function MetricCard({
metric,
metricData,
selected,
onClick,
}: {
metric: MetricConfig;
metricData: MetricData;
selected: boolean;
onClick: () => void;
}) {
const hasData = metricData.points.length > 0;
const cardStyle: React.CSSProperties = {
minWidth: 130,
padding: '8px 12px',
background: 'var(--ant-color-bg-container, #fafafa)',
borderRadius: 8,
border: selected
? `2px solid ${metric.color}`
: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
cursor: hasData ? 'pointer' : 'default',
transition: 'border-color 0.2s, box-shadow 0.2s',
flex: '0 0 auto',
...(selected && hasData ? { boxShadow: `0 0 0 1px ${metric.color}22` } : {}),
};
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={cardStyle} onClick={hasData ? onClick : undefined}>
<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>
{metricData.latest}
<span style={{ fontWeight: 400, fontSize: 10, marginLeft: 2 }}>{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}
/>
{hasData ? (
<Line
data={metricData.points}
xField="date"
yField="value"
smooth
height={32}
axis={false}
point={false}
style={{ lineWidth: 1.5, stroke: metric.color }}
tooltip={false}
/>
) : (
<div style={{ height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text type="secondary" style={{ fontSize: 10 }}></Text>
</div>
)}
</div>
);
}
@@ -89,6 +103,8 @@ function MetricCard({ metric, metricData }: { metric: MetricConfig; metricData:
export function VitalSignsChart({ patientId, refreshKey }: Props) {
const [metricsData, setMetricsData] = useState<Record<string, MetricData>>({});
const [loading, setLoading] = useState(true);
const [selectedKey, setSelectedKey] = useState<string | null>(null);
const isDark = useThemeMode();
useEffect(() => {
if (!patientId) return;
@@ -110,7 +126,7 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
if (r.status === 'fulfilled') {
map[r.value.key] = r.value.data;
} else {
map[METRICS[i].key] = { points: [], latest: null };
map[METRICS[i].key] = emptyData;
}
});
setMetricsData(map);
@@ -120,7 +136,7 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
if (loading) {
return (
<div style={{ height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Spin size="small" />
</div>
);
@@ -144,15 +160,69 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
);
}
const selectedMetric = METRICS.find((m) => m.key === selectedKey);
const selectedData = selectedKey ? metricsData[selectedKey] : null;
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return (
<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>
{/* 概览卡片条 */}
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
{METRICS.map((m) => (
<MetricCard
key={m.key}
metric={m}
metricData={metricsData[m.key] ?? emptyData}
selected={selectedKey === m.key}
onClick={() => setSelectedKey((prev) => (prev === m.key ? null : m.key))}
/>
))}
</div>
{/* 详情图区域 */}
{selectedMetric && selectedData && selectedData.points.length > 0 && (
<div style={{
marginTop: 12,
padding: 16,
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: 8 }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<Text style={{ fontSize: 14, fontWeight: 600 }}>{selectedMetric.label}</Text>
<Text style={{ fontSize: 18, fontWeight: 700, color: selectedMetric.color }}>
{selectedData.latest}
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{selectedMetric.unit}</Text>
</div>
<div
onClick={() => setSelectedKey(null)}
style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--ant-color-text-secondary)' }}
>
<CloseOutlined style={{ fontSize: 12 }} />
</div>
</div>
<Line
data={selectedData.points}
xField="date"
yField="value"
smooth
height={200}
point={{ shapeField: 'circle', sizeField: 3 }}
axis={{
x: { labelAutoRotate: true, label: { style: axisLabelStyle } },
y: { title: selectedMetric.unit, label: { style: axisLabelStyle } },
}}
style={{ lineWidth: 2, stroke: selectedMetric.color }}
tooltip={{
title: (d) => d.date,
items: [{ channel: 'y', valueFormatter: (v: number) => `${v} ${selectedMetric.unit}` }],
}}
/>
</div>
)}
</div>
);
}