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 { 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>
); );
} }