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 { useEffect, useState } from 'react';
|
||||||
import { Line } from '@ant-design/charts';
|
import { Line } from '@ant-design/charts';
|
||||||
import { Spin, Typography } from 'antd';
|
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 { healthDataApi } from '../../../api/health/healthData';
|
||||||
|
import { useThemeMode } from '../../../hooks/useThemeMode';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -16,14 +17,15 @@ interface MetricConfig {
|
|||||||
label: string;
|
label: string;
|
||||||
unit: string;
|
unit: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
normalRange?: [number, number];
|
||||||
}
|
}
|
||||||
|
|
||||||
const METRICS: MetricConfig[] = [
|
const METRICS: MetricConfig[] = [
|
||||||
{ key: 'systolic_bp_morning', label: '收缩压(晨)', unit: 'mmHg', color: '#ef4444' },
|
{ key: 'systolic_bp_morning', label: '收缩压(晨)', unit: 'mmHg', color: '#ef4444', normalRange: [90, 140] },
|
||||||
{ key: 'diastolic_bp_morning', label: '舒张压(晨)', unit: 'mmHg', color: '#f97316' },
|
{ key: 'diastolic_bp_morning', label: '舒张压(晨)', unit: 'mmHg', color: '#f97316', normalRange: [60, 90] },
|
||||||
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#3b82f6' },
|
{ key: 'heart_rate', label: '心率', unit: 'bpm', color: '#3b82f6', normalRange: [60, 100] },
|
||||||
{ key: 'weight', label: '体重', unit: 'kg', color: '#10b981' },
|
{ 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 {
|
interface MetricData {
|
||||||
@@ -37,51 +39,63 @@ function extractData(res: unknown): { date: string; value: number }[] {
|
|||||||
return arr.filter((d) => d?.value != null);
|
return arr.filter((d) => d?.value != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 单个指标迷你卡片 */
|
const emptyData: MetricData = { points: [], latest: null };
|
||||||
function MetricCard({ metric, metricData }: { metric: MetricConfig; metricData: MetricData }) {
|
|
||||||
if (metricData.points.length === 0) {
|
/** 概览卡片 — 指标名 + 最新值 + 微型趋势线 */
|
||||||
return (
|
function MetricCard({
|
||||||
<div style={{
|
metric,
|
||||||
padding: '8px 12px',
|
metricData,
|
||||||
background: 'var(--ant-color-bg-container, #fafafa)',
|
selected,
|
||||||
borderRadius: 8,
|
onClick,
|
||||||
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
}: {
|
||||||
display: 'flex',
|
metric: MetricConfig;
|
||||||
alignItems: 'center',
|
metricData: MetricData;
|
||||||
gap: 8,
|
selected: boolean;
|
||||||
}}>
|
onClick: () => void;
|
||||||
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf' }} />
|
}) {
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>暂无{metric.label}数据</Text>
|
const hasData = metricData.points.length > 0;
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div style={{
|
<div style={cardStyle} onClick={hasData ? onClick : undefined}>
|
||||||
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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
|
||||||
<Text style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)' }}>{metric.label}</Text>
|
<Text style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)' }}>{metric.label}</Text>
|
||||||
{metricData.latest != null && (
|
{metricData.latest != null && (
|
||||||
<Text style={{ fontSize: 12, fontWeight: 600, color: metric.color }}>
|
<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>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Line
|
{hasData ? (
|
||||||
data={metricData.points}
|
<Line
|
||||||
xField="date"
|
data={metricData.points}
|
||||||
yField="value"
|
xField="date"
|
||||||
smooth
|
yField="value"
|
||||||
height={60}
|
smooth
|
||||||
axis={false}
|
height={32}
|
||||||
point={{ shapeField: 'circle', sizeField: 2 }}
|
axis={false}
|
||||||
style={{ lineWidth: 1.5, stroke: metric.color }}
|
point={false}
|
||||||
tooltip={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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,6 +103,8 @@ function MetricCard({ metric, metricData }: { metric: MetricConfig; metricData:
|
|||||||
export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
||||||
const [metricsData, setMetricsData] = useState<Record<string, MetricData>>({});
|
const [metricsData, setMetricsData] = useState<Record<string, MetricData>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||||
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!patientId) return;
|
if (!patientId) return;
|
||||||
@@ -110,7 +126,7 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
if (r.status === 'fulfilled') {
|
if (r.status === 'fulfilled') {
|
||||||
map[r.value.key] = r.value.data;
|
map[r.value.key] = r.value.data;
|
||||||
} else {
|
} else {
|
||||||
map[METRICS[i].key] = { points: [], latest: null };
|
map[METRICS[i].key] = emptyData;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setMetricsData(map);
|
setMetricsData(map);
|
||||||
@@ -120,7 +136,7 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<div style={{
|
<div>
|
||||||
display: 'grid',
|
{/* 概览卡片条 */}
|
||||||
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
|
<div style={{ display: 'flex', gap: 8, overflowX: 'auto', paddingBottom: 4 }}>
|
||||||
gap: 8,
|
{METRICS.map((m) => (
|
||||||
}}>
|
<MetricCard
|
||||||
{METRICS.map((m) => (
|
key={m.key}
|
||||||
<MetricCard key={m.key} metric={m} metricData={metricsData[m.key] ?? { points: [], latest: null }} />
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user