fix(web): VitalSignsChart hooks 顺序修复 + 趋势线颜色区分度优化
修复 React hooks 在 early return 之后调用导致的渲染崩溃, 将所有 useMemo 移至条件返回之前。趋势图三系列改用高对比色: 实际值(原色实线)、移动平均(青色短虚线)、趋势线(琥珀色长虚线)。
This commit is contained in:
@@ -214,7 +214,7 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
});
|
});
|
||||||
}, [patientId, refreshKey]);
|
}, [patientId, refreshKey]);
|
||||||
|
|
||||||
// 为每个指标预计算趋势统计
|
// 趋势统计 — 所有 hooks 必须在 early return 之前调用
|
||||||
const trendStats = useMemo(() => {
|
const trendStats = useMemo(() => {
|
||||||
const stats: Record<string, {
|
const stats: Record<string, {
|
||||||
regression: ReturnType<typeof linearRegression>;
|
regression: ReturnType<typeof linearRegression>;
|
||||||
@@ -240,6 +240,81 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
return stats;
|
return stats;
|
||||||
}, [metricsData]);
|
}, [metricsData]);
|
||||||
|
|
||||||
|
const selectedMetric = METRICS.find((m) => m.key === selectedKey);
|
||||||
|
const selectedData = selectedKey ? metricsData[selectedKey] : null;
|
||||||
|
const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
|
||||||
|
|
||||||
|
const chartSeries = useMemo(() => {
|
||||||
|
if (!selectedKey || !selectedData) return [];
|
||||||
|
const stat = trendStats[selectedKey];
|
||||||
|
if (!stat) return [];
|
||||||
|
|
||||||
|
const points = selectedData.points;
|
||||||
|
const series: { date: string; value: number; series: string; isAnomaly?: boolean }[] = [];
|
||||||
|
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
series.push({
|
||||||
|
date: p.date,
|
||||||
|
value: p.value,
|
||||||
|
series: '实际值',
|
||||||
|
isAnomaly: stat.anomalyIndices.has(i),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stat.ma.forEach((val, i) => {
|
||||||
|
if (val != null) {
|
||||||
|
series.push({
|
||||||
|
date: points[i].date,
|
||||||
|
value: val,
|
||||||
|
series: '移动平均',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stat.regression) {
|
||||||
|
const { intercept, slope } = stat.regression;
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
series.push({
|
||||||
|
date: p.date,
|
||||||
|
value: intercept + slope * i,
|
||||||
|
series: '趋势线',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
}, [selectedKey, selectedData, trendStats]);
|
||||||
|
|
||||||
|
const getTrendDirection = (key: string): 'rising' | 'falling' | 'stable' | null => {
|
||||||
|
const stat = trendStats[key];
|
||||||
|
if (!stat?.regression) return null;
|
||||||
|
const { slope } = stat.regression;
|
||||||
|
if (Math.abs(slope) < 0.01) return 'stable';
|
||||||
|
return slope > 0 ? 'rising' : 'falling';
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendDescription = useMemo(() => {
|
||||||
|
if (!selectedKey || !selectedData || !trendStats[selectedKey]) return null;
|
||||||
|
const stat = trendStats[selectedKey];
|
||||||
|
if (!stat?.regression) return null;
|
||||||
|
|
||||||
|
const { slope, rSquared } = stat.regression;
|
||||||
|
const direction = getTrendDirection(selectedKey);
|
||||||
|
const anomalyCount = stat.anomalyIndices.size;
|
||||||
|
const points = selectedData.points;
|
||||||
|
const totalChange = slope * (points.length - 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
direction: direction === 'rising' ? '上升' : direction === 'falling' ? '下降' : '平稳',
|
||||||
|
slope,
|
||||||
|
rSquared,
|
||||||
|
totalChange,
|
||||||
|
periodDays: points.length > 1 ? points.length : 0,
|
||||||
|
anomalyCount,
|
||||||
|
};
|
||||||
|
}, [selectedKey, selectedData, trendStats]);
|
||||||
|
|
||||||
|
// Early returns — 放在所有 hooks 之后
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -266,87 +341,6 @@ 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' };
|
|
||||||
|
|
||||||
// 为详情图构建多系列数据
|
|
||||||
const chartSeries = useMemo(() => {
|
|
||||||
if (!selectedKey || !selectedData) return [];
|
|
||||||
const stat = trendStats[selectedKey];
|
|
||||||
if (!stat) return [];
|
|
||||||
|
|
||||||
const points = selectedData.points;
|
|
||||||
const series: { date: string; value: number; series: string; isAnomaly?: boolean }[] = [];
|
|
||||||
|
|
||||||
// 原始数据系列
|
|
||||||
points.forEach((p, i) => {
|
|
||||||
series.push({
|
|
||||||
date: p.date,
|
|
||||||
value: p.value,
|
|
||||||
series: '实际值',
|
|
||||||
isAnomaly: stat.anomalyIndices.has(i),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 移动平均系列
|
|
||||||
stat.ma.forEach((val, i) => {
|
|
||||||
if (val != null) {
|
|
||||||
series.push({
|
|
||||||
date: points[i].date,
|
|
||||||
value: val,
|
|
||||||
series: '移动平均',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 趋势线系列(线性回归)
|
|
||||||
if (stat.regression) {
|
|
||||||
const { intercept, slope } = stat.regression;
|
|
||||||
points.forEach((p, i) => {
|
|
||||||
series.push({
|
|
||||||
date: p.date,
|
|
||||||
value: intercept + slope * i,
|
|
||||||
series: '趋势线',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return series;
|
|
||||||
}, [selectedKey, selectedData, trendStats]);
|
|
||||||
|
|
||||||
// 趋势方向摘要
|
|
||||||
const getTrendDirection = (key: string): 'rising' | 'falling' | 'stable' | null => {
|
|
||||||
const stat = trendStats[key];
|
|
||||||
if (!stat?.regression) return null;
|
|
||||||
const { slope } = stat.regression;
|
|
||||||
if (Math.abs(slope) < 0.01) return 'stable';
|
|
||||||
return slope > 0 ? 'rising' : 'falling';
|
|
||||||
};
|
|
||||||
|
|
||||||
// 趋势描述文本
|
|
||||||
const trendDescription = useMemo(() => {
|
|
||||||
if (!selectedKey || !selectedData || !trendStats[selectedKey]) return null;
|
|
||||||
const stat = trendStats[selectedKey];
|
|
||||||
if (!stat?.regression) return null;
|
|
||||||
|
|
||||||
const { slope, rSquared } = stat.regression;
|
|
||||||
const direction = getTrendDirection(selectedKey);
|
|
||||||
const anomalyCount = stat.anomalyIndices.size;
|
|
||||||
const points = selectedData.points;
|
|
||||||
const periodDays = points.length > 1 ? points.length : 0;
|
|
||||||
const totalChange = slope * (points.length - 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
direction: direction === 'rising' ? '上升' : direction === 'falling' ? '下降' : '平稳',
|
|
||||||
slope,
|
|
||||||
rSquared,
|
|
||||||
totalChange,
|
|
||||||
periodDays,
|
|
||||||
anomalyCount,
|
|
||||||
};
|
|
||||||
}, [selectedKey, selectedData, trendStats]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* 概览卡片条 */}
|
{/* 概览卡片条 */}
|
||||||
@@ -426,17 +420,22 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
y: { title: selectedMetric.unit, label: { style: axisLabelStyle } },
|
y: { title: selectedMetric.unit, label: { style: axisLabelStyle } },
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
lineWidth: 2,
|
lineWidth: (datum: Record<string, unknown>) => {
|
||||||
|
const series = datum.series as string;
|
||||||
|
if (series === '趋势线') return 1.5;
|
||||||
|
if (series === '移动平均') return 2;
|
||||||
|
return 2.5;
|
||||||
|
},
|
||||||
stroke: (datum: Record<string, unknown>) => {
|
stroke: (datum: Record<string, unknown>) => {
|
||||||
const series = datum.series as string;
|
const series = datum.series as string;
|
||||||
if (series === '趋势线') return '#94a3b8';
|
if (series === '趋势线') return '#f59e0b';
|
||||||
if (series === '移动平均') return selectedMetric.color + '88';
|
if (series === '移动平均') return '#14b8a6';
|
||||||
return selectedMetric.color;
|
return selectedMetric.color;
|
||||||
},
|
},
|
||||||
lineDash: (datum: Record<string, unknown>) => {
|
lineDash: (datum: Record<string, unknown>) => {
|
||||||
const series = datum.series as string;
|
const series = datum.series as string;
|
||||||
if (series === '趋势线') return [6, 3];
|
if (series === '趋势线') return [8, 4];
|
||||||
if (series === '移动平均') return [];
|
if (series === '移动平均') return [4, 2];
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -463,16 +462,16 @@ export function VitalSignsChart({ patientId, refreshKey }: Props) {
|
|||||||
{/* 图例说明 */}
|
{/* 图例说明 */}
|
||||||
<div style={{ display: 'flex', gap: 16, marginTop: 4, justifyContent: 'center' }}>
|
<div style={{ display: 'flex', gap: 16, marginTop: 4, justifyContent: 'center' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 16, height: 2, background: selectedMetric.color }} />
|
<div style={{ width: 16, height: 2.5, background: selectedMetric.color, borderRadius: 1 }} />
|
||||||
<Text type="secondary" style={{ fontSize: 10 }}>实际值</Text>
|
<Text type="secondary" style={{ fontSize: 10 }}>实际值</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 16, borderTop: '2px dashed #94a3b8' }} />
|
<div style={{ width: 16, height: 2, background: '#14b8a6', borderRadius: 1, backgroundRepeat: 'repeat-x', backgroundImage: 'linear-gradient(#14b8a6, #14b8a6)', backgroundSize: '4px 2px', backgroundPosition: '0 0' }} />
|
||||||
<Text type="secondary" style={{ fontSize: 10 }}>趋势线</Text>
|
<Text type="secondary" style={{ fontSize: 10 }}>移动平均</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 16, height: 2, background: selectedMetric.color + '88' }} />
|
<div style={{ width: 16, borderTop: '2px dashed #f59e0b' }} />
|
||||||
<Text type="secondary" style={{ fontSize: 10 }}>移动平均</Text>
|
<Text type="secondary" style={{ fontSize: 10 }}>趋势线</Text>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
|
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#ef4444' }} />
|
||||||
|
|||||||
Reference in New Issue
Block a user