feat(web): 多指标趋势图重设计 — 概览卡片条 + 点击展开详情图
交互模式: 水平概览卡片条(指标名+最新值+32px微型趋势线) → 点击展开200px详情折线图(坐标轴+tooltip+关闭按钮) 5项指标独立Y轴,解决量级差异问题(血压~120 vs 血糖~5)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user