fix(health): 趋势图数据不显示 — 后端 DTO 元组→结构体 + 前端解包修复
- 后端 IndicatorTimeseriesResp.data 从 Vec<(NaiveDate, f64)> 改为 Vec<DataPoint>
解决 JSON 序列化为数组而非对象导致前端无法识别的问题
- 前端 VitalSignsChart 正确解包 API 返回的 { indicator, data } 响应结构
- 移除趋势图无用的指标下拉选择器,固定显示收缩压(晨)趋势
- 修复 PatientDetail Card body padding 三层嵌套空白问题
This commit is contained in:
@@ -142,6 +142,7 @@ export default function PatientDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
<style>{`.patient-detail-tabs-card .ant-card-body { padding: 0 !important; }`}</style>
|
||||||
{/* 顶部导航 */}
|
{/* 顶部导航 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
<Button
|
<Button
|
||||||
@@ -216,10 +217,10 @@ export default function PatientDetail() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 标签页 */}
|
<Card style={cardStyle} className="patient-detail-tabs-card">
|
||||||
<Card style={cardStyle}>
|
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="info"
|
defaultActiveKey="info"
|
||||||
|
style={{ paddingLeft: 16, paddingRight: 16 }}
|
||||||
items={[
|
items={[
|
||||||
{
|
{
|
||||||
key: 'info',
|
key: 'info',
|
||||||
@@ -272,6 +273,7 @@ export default function PatientDetail() {
|
|||||||
children: id ? (
|
children: id ? (
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultActiveKey="vital"
|
defaultActiveKey="vital"
|
||||||
|
size="small"
|
||||||
items={[
|
items={[
|
||||||
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
|
{ key: 'vital', label: '体征数据', children: <VitalSignsTab patientId={id} /> },
|
||||||
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
|
{ key: 'lab', label: '化验报告', children: <LabReportsTab patientId={id} /> },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Line } from '@ant-design/charts';
|
import { Line } from '@ant-design/charts';
|
||||||
import { Spin, Select, Alert, Typography } from 'antd';
|
import { Spin, Alert, Typography } from 'antd';
|
||||||
import { LineChartOutlined } from '@ant-design/icons';
|
import { LineChartOutlined } from '@ant-design/icons';
|
||||||
import { healthDataApi } from '../../../api/health/healthData';
|
import { healthDataApi } from '../../../api/health/healthData';
|
||||||
|
|
||||||
@@ -8,129 +8,86 @@ const { Text } = Typography;
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
patientId: string;
|
patientId: string;
|
||||||
indicator?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDICATORS = [
|
const DEFAULT_INDICATOR = 'systolic_bp_morning';
|
||||||
{ value: 'systolic_bp_morning', label: '收缩压(晨)' },
|
const UNIT = 'mmHg';
|
||||||
{ value: 'diastolic_bp_morning', label: '舒张压(晨)' },
|
const LABEL = '收缩压(晨)';
|
||||||
{ value: 'heart_rate', label: '心率' },
|
|
||||||
{ value: 'weight', label: '体重' },
|
|
||||||
{ value: 'blood_sugar', label: '血糖' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const INDICATOR_UNITS: Record<string, string> = {
|
export function VitalSignsChart({ patientId }: Props) {
|
||||||
systolic_bp_morning: 'mmHg',
|
|
||||||
diastolic_bp_morning: 'mmHg',
|
|
||||||
heart_rate: 'bpm',
|
|
||||||
weight: 'kg',
|
|
||||||
blood_sugar: 'mmol/L',
|
|
||||||
};
|
|
||||||
|
|
||||||
export function VitalSignsChart({ patientId, indicator: initialIndicator }: Props) {
|
|
||||||
const [indicator, setIndicator] = useState(initialIndicator ?? 'systolic_bp_morning');
|
|
||||||
const [data, setData] = useState<{ date: string; value: number }[]>([]);
|
const [data, setData] = useState<{ date: string; value: number }[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!patientId || !indicator) return;
|
if (!patientId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
healthDataApi
|
healthDataApi
|
||||||
.getIndicatorTimeseries(patientId, indicator)
|
.getIndicatorTimeseries(patientId, DEFAULT_INDICATOR)
|
||||||
.then(setData)
|
.then((res) => {
|
||||||
|
const raw = (res as { data?: { date: string; value: number }[] })?.data;
|
||||||
|
const points = Array.isArray(res) ? res : Array.isArray(raw) ? raw : [];
|
||||||
|
setData(points.filter((d) => d?.value != null));
|
||||||
|
})
|
||||||
.catch(() => setError(true))
|
.catch(() => setError(true))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [patientId, indicator]);
|
}, [patientId]);
|
||||||
|
|
||||||
const unit = INDICATOR_UNITS[indicator] ?? '';
|
|
||||||
|
|
||||||
// 头部:选择器 + 最新值摘要
|
|
||||||
const latestValue = data.length > 0 ? data[data.length - 1].value : null;
|
|
||||||
|
|
||||||
const renderHeader = () => (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 12,
|
|
||||||
}}>
|
|
||||||
<Select
|
|
||||||
value={indicator}
|
|
||||||
onChange={setIndicator}
|
|
||||||
options={INDICATORS}
|
|
||||||
style={{ width: 160 }}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
{latestValue != null && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
||||||
最新:{latestValue} {unit}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ height: 60, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return <Alert type="error" message="加载趋势数据失败" showIcon style={{ borderRadius: 8 }} />;
|
||||||
<div>
|
|
||||||
{renderHeader()}
|
|
||||||
<Alert type="error" message="加载数据失败" showIcon style={{ borderRadius: 8 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{
|
||||||
{renderHeader()}
|
height: 48,
|
||||||
<div style={{
|
display: 'flex',
|
||||||
height: 120,
|
alignItems: 'center',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
flexDirection: 'column',
|
background: 'var(--ant-color-bg-container, #fafafa)',
|
||||||
alignItems: 'center',
|
borderRadius: 6,
|
||||||
justifyContent: 'center',
|
border: '1px dashed var(--ant-color-border, #d9d9d9)',
|
||||||
background: 'var(--ant-color-bg-container, #fafafa)',
|
}}>
|
||||||
borderRadius: 8,
|
<LineChartOutlined style={{ fontSize: 14, color: '#bfbfbf', marginRight: 6 }} />
|
||||||
border: '1px dashed var(--ant-color-border, #d9d9d9)',
|
<Text type="secondary" style={{ fontSize: 12 }}>暂无{LABEL}趋势数据</Text>
|
||||||
}}>
|
|
||||||
<LineChartOutlined style={{ fontSize: 24, color: '#bfbfbf', marginBottom: 4 }} />
|
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>暂无趋势数据</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = {
|
const latestValue = data[data.length - 1].value;
|
||||||
data,
|
|
||||||
xField: 'date',
|
|
||||||
yField: 'value',
|
|
||||||
smooth: true,
|
|
||||||
height: 180,
|
|
||||||
point: { shapeField: 'circle', sizeField: 3 },
|
|
||||||
axis: {
|
|
||||||
x: { labelAutoRotate: false },
|
|
||||||
y: { title: unit || undefined },
|
|
||||||
},
|
|
||||||
style: {
|
|
||||||
lineWidth: 2,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{renderHeader()}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||||
<div style={{ width: '100%' }}>
|
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{LABEL}趋势</Text>
|
||||||
<Line {...config} />
|
{latestValue != null && (
|
||||||
|
<Text style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>
|
||||||
|
最新:{latestValue} {UNIT}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<Line
|
||||||
|
data={data}
|
||||||
|
xField="date"
|
||||||
|
yField="value"
|
||||||
|
smooth
|
||||||
|
height={140}
|
||||||
|
point={{ shapeField: 'circle', sizeField: 3 }}
|
||||||
|
axis={{
|
||||||
|
x: { labelAutoRotate: false },
|
||||||
|
y: { title: UNIT },
|
||||||
|
}}
|
||||||
|
style={{ lineWidth: 2 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ pub struct TrendResp {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct IndicatorTimeseriesResp {
|
pub struct IndicatorTimeseriesResp {
|
||||||
pub indicator: String,
|
pub indicator: String,
|
||||||
pub data: Vec<(NaiveDate, f64)>,
|
pub data: Vec<DataPoint>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ pub async fn get_indicator_timeseries(
|
|||||||
.all(&state.db)
|
.all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let data: Vec<(chrono::NaiveDate, f64)> = vitals.into_iter().filter_map(|v| {
|
let data: Vec<DataPoint> = vitals.into_iter().filter_map(|v| {
|
||||||
let val = match indicator.as_str() {
|
let val = match indicator.as_str() {
|
||||||
"heart_rate" => v.heart_rate.map(|x| x as f64),
|
"heart_rate" => v.heart_rate.map(|x| x as f64),
|
||||||
"weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)),
|
"weight" => v.weight.map(|d| d.to_f64().unwrap_or(0.0)),
|
||||||
@@ -225,7 +225,10 @@ pub async fn get_indicator_timeseries(
|
|||||||
"diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64),
|
"diastolic_bp_evening" => v.diastolic_bp_evening.map(|x| x as f64),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
val.map(|fv| (v.record_date, fv))
|
val.map(|fv| DataPoint {
|
||||||
|
date: v.record_date.to_string(),
|
||||||
|
value: fv,
|
||||||
|
})
|
||||||
}).collect();
|
}).collect();
|
||||||
|
|
||||||
Ok(IndicatorTimeseriesResp { indicator, data })
|
Ok(IndicatorTimeseriesResp { indicator, data })
|
||||||
@@ -393,15 +396,8 @@ pub async fn get_mini_trend(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 4. 转换为 DataPoint 格式
|
// 4. 直接使用 DataPoint
|
||||||
let data_points = timeseries
|
let data_points = timeseries.data;
|
||||||
.data
|
|
||||||
.into_iter()
|
|
||||||
.map(|(date, value)| DataPoint {
|
|
||||||
date: date.to_string(),
|
|
||||||
value,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(MiniTrendResp {
|
Ok(MiniTrendResp {
|
||||||
indicator,
|
indicator,
|
||||||
|
|||||||
Reference in New Issue
Block a user