import { useEffect, useMemo, useState } from 'react'; import { Line } from '@ant-design/charts'; import { Spin, Typography, Tag } from 'antd'; import { LineChartOutlined, CloseOutlined, WarningOutlined } from '@ant-design/icons'; import { healthDataApi } from '../../../api/health/healthData'; import { useThemeMode } from '../../../hooks/useThemeMode'; const { Text } = Typography; // --------------------------------------------------------------------------- // 统计计算工具函数(与后端 trend_stats.rs 对齐) // --------------------------------------------------------------------------- /** 最小二乘法线性回归 */ function linearRegression(points: { date: string; value: number }[]) { const n = points.length; if (n < 2) return null; const xs = points.map((_, i) => i); const ys = points.map((p) => p.value); const sumX = xs.reduce((a, b) => a + b, 0); const sumY = ys.reduce((a, b) => a + b, 0); const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0); const sumX2 = xs.reduce((acc, x) => acc + x * x, 0); const denom = n * sumX2 - sumX * sumX; if (Math.abs(denom) < 1e-10) return null; const slope = (n * sumXY - sumX * sumY) / denom; const intercept = (sumY - slope * sumX) / n; const meanY = sumY / n; const ssTot = ys.reduce((acc, y) => acc + (y - meanY) ** 2, 0); const rSquared = ssTot > 0 ? 1 - ys.reduce((acc, y, i) => acc + (y - (intercept + slope * i)) ** 2, 0) / ssTot : 1; return { slope, intercept, rSquared }; } /** 移动平均 */ function movingAverage(values: number[], window: number): (number | null)[] { if (values.length === 0 || window === 0) return []; return values.map((_, i) => { if (i + 1 < window) return null; const slice = values.slice(i + 1 - window, i + 1); return slice.reduce((a, b) => a + b, 0) / window; }); } /** 异常检测 */ function detectAnomalies(points: { date: string; value: number }[], threshold: number) { if (points.length < 3) return new Set(); const values = points.map((p) => p.value); const n = values.length; const mean = values.reduce((a, b) => a + b, 0) / n; const variance = values.reduce((acc, v) => acc + (v - mean) ** 2, 0) / n; const stdDev = Math.sqrt(variance); if (stdDev < 1e-10) return new Set(); const anomalies = new Set(); values.forEach((v, i) => { if (Math.abs((v - mean) / stdDev) > threshold) { anomalies.add(i); } }); return anomalies; } // --------------------------------------------------------------------------- // 组件 // --------------------------------------------------------------------------- interface Props { patientId: string; refreshKey?: number; } interface MetricConfig { key: string; label: string; unit: string; color: string; normalRange?: [number, number]; } const METRICS: MetricConfig[] = [ { 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', normalRange: [3.9, 6.1] }, ]; interface MetricData { points: { date: string; value: number }[]; latest: number | null; } function extractData(res: unknown): { date: string; value: number }[] { const raw = (res as { data?: { date: string; value: number }[] })?.data; const arr = Array.isArray(res) ? res : Array.isArray(raw) ? raw : []; return arr.filter((d) => d?.value != null); } const emptyData: MetricData = { points: [], latest: null }; /** 概览卡片 — 指标名 + 最新值 + 趋势方向 + 微型趋势线 */ function MetricCard({ metric, metricData, selected, onClick, trendDirection, anomalyCount, }: { metric: MetricConfig; metricData: MetricData; selected: boolean; onClick: () => void; trendDirection: 'rising' | 'falling' | 'stable' | null; anomalyCount: number; }) { 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` } : {}), }; const directionIcon = trendDirection === 'rising' ? '↑' : trendDirection === 'falling' ? '↓' : '→'; const directionColor = trendDirection === 'rising' ? '#ef4444' : trendDirection === 'falling' ? '#3b82f6' : '#94a3b8'; return (
{metric.label}
{anomalyCount > 0 && ( )} {trendDirection && ( {directionIcon} )} {metricData.latest != null && ( {metricData.latest} {metric.unit} )}
{hasData ? ( ) : (
暂无数据
)}
); } export function VitalSignsChart({ patientId, refreshKey }: Props) { const [metricsData, setMetricsData] = useState>({}); const [loading, setLoading] = useState(true); const [selectedKey, setSelectedKey] = useState(null); const isDark = useThemeMode(); useEffect(() => { if (!patientId) return; setLoading(true); Promise.allSettled( METRICS.map((m) => healthDataApi .getIndicatorTimeseries(patientId, m.key) .then(extractData) .then((points) => ({ key: m.key, data: { points, latest: points.length > 0 ? points[points.length - 1].value : null }, })) ) ).then((results) => { const map: Record = {}; results.forEach((r, i) => { if (r.status === 'fulfilled') { map[r.value.key] = r.value.data; } else { map[METRICS[i].key] = emptyData; } }); setMetricsData(map); setLoading(false); }); }, [patientId, refreshKey]); // 趋势统计 — 所有 hooks 必须在 early return 之前调用 const trendStats = useMemo(() => { const stats: Record; ma: (number | null)[]; anomalyIndices: Set; }> = {}; for (const m of METRICS) { const data = metricsData[m.key]; if (!data || data.points.length < 2) { stats[m.key] = { regression: null, ma: [], anomalyIndices: new Set() }; continue; } const regression = linearRegression(data.points); const values = data.points.map((p) => p.value); const maWindow = Math.min(7, Math.max(3, Math.floor(values.length / 3))); const ma = movingAverage(values, maWindow); const anomalyIndices = detectAnomalies(data.points, 2.0); stats[m.key] = { regression, ma, anomalyIndices }; } return stats; }, [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) { return (
); } const hasAnyData = Object.values(metricsData).some((d) => d.points.length > 0); if (!hasAnyData) { return (
暂无趋势数据
); } return (
{/* 概览卡片条 */}
{METRICS.map((m) => ( setSelectedKey((prev) => (prev === m.key ? null : m.key))} trendDirection={getTrendDirection(m.key)} anomalyCount={trendStats[m.key]?.anomalyIndices.size ?? 0} /> ))}
{/* 详情图区域 */} {selectedMetric && selectedData && selectedData.points.length > 0 && (
{selectedMetric.label} {selectedData.latest} {selectedMetric.unit} {trendDescription && ( {trendDescription.direction} ({trendDescription.totalChange > 0 ? '+' : ''}{trendDescription.totalChange.toFixed(1)}) )}
setSelectedKey(null)} style={{ cursor: 'pointer', padding: '2px 6px', borderRadius: 4, color: 'var(--ant-color-text-secondary)' }} >
{/* 趋势统计摘要 */} {trendDescription && (
趋势斜率: {trendDescription.slope > 0 ? '+' : ''}{trendDescription.slope.toFixed(2)}/{selectedMetric.unit}/天 R²: {trendDescription.rSquared.toFixed(3)} {trendDescription.anomalyCount > 0 && ( {trendDescription.anomalyCount} 个异常点 )}
)} ) => { const series = datum.series as string; if (series === '趋势线') return 1.5; if (series === '移动平均') return 2; return 2.5; }, stroke: (datum: Record) => { const series = datum.series as string; if (series === '趋势线') return '#f59e0b'; if (series === '移动平均') return '#14b8a6'; return selectedMetric.color; }, lineDash: (datum: Record) => { const series = datum.series as string; if (series === '趋势线') return [8, 4]; if (series === '移动平均') return [4, 2]; return []; }, }} point={{ shapeField: 'circle', sizeField: 4, style: { fill: (datum: Record) => { if (datum.isAnomaly) return '#ef4444'; return selectedMetric.color; }, r: (datum: Record) => { if (datum.isAnomaly) return 5; return 3; }, }, }} tooltip={{ title: (d: Record) => d.date as string, items: [{ channel: 'y', valueFormatter: (v: number) => `${v} ${selectedMetric.unit}` }], }} /> {/* 图例说明 */}
实际值
移动平均
趋势线
异常点
)}
); }