Files
hms/apps/web/src/pages/health/components/VitalSignsTab.tsx
iven f3bf8b3b1d fix: DTO 输入校验补全 + 编译修复 + AuthButton 类型修复
- erp-auth/config/workflow/message/plugin/health: 44 处 DTO 校验缺失修复
- erp-plugin/data_dto: utoipa derive 宏 import 修复
- erp-server/main: tracing 宏类型推断修复
- web AuthButton: AiAnalysisCard/VitalSignsTab Button 包裹在 children 内

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 06:58:54 +08:00

368 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message, Typography, Tooltip, Popconfirm, Space, Card } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import { dayjs } from '../../../utils/dayjs';
import { healthDataApi } from '../../../api/health/healthData';
import type { VitalSigns } from '../../../api/health/healthData';
import { VitalSignsChart } from './VitalSignsChart';
import { usePaginatedData } from '../../../hooks/usePaginatedData';
import { AuthButton } from '../../../components/AuthButton';
import { handleApiError } from '../../../api/client';
import { startAnalysis } from '../../../api/ai/analysisSse';
const { Text } = Typography;
interface Props {
patientId: string;
}
export function VitalSignsTab({ patientId }: Props) {
const [modalOpen, setModalOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<VitalSigns | null>(null);
const [chartRefreshKey, setChartRefreshKey] = useState(0);
const [analyzingTrend, setAnalyzingTrend] = useState(false);
const [trendContent, setTrendContent] = useState('');
const [form] = Form.useForm();
const [submitting, setSubmitting] = useState(false);
const fetcher = useCallback(
async (page: number, pageSize: number) => {
return healthDataApi.listVitalSigns(patientId, { page, page_size: pageSize });
},
[patientId],
);
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
const handleTrendAnalysis = async () => {
setAnalyzingTrend(true);
setTrendContent('');
await startAnalysis('trends', { patient_id: patientId }, {
onChunk: (content) => setTrendContent(prev => prev + content),
onError: (msg) => { message.error(msg); setAnalyzingTrend(false); },
onDone: () => { message.success('AI 趋势分析完成'); setAnalyzingTrend(false); },
});
};
const handleOpenCreate = () => {
setEditingRecord(null);
form.resetFields();
setModalOpen(true);
};
const handleOpenEdit = (record: VitalSigns) => {
setEditingRecord(record);
form.setFieldsValue({
record_date: dayjs(record.record_date),
systolic_bp_morning: record.systolic_bp_morning,
diastolic_bp_morning: record.diastolic_bp_morning,
heart_rate: record.heart_rate,
weight: record.weight,
blood_sugar: record.blood_sugar,
water_intake_ml: record.water_intake_ml,
urine_output_ml: record.urine_output_ml,
notes: record.notes,
});
setModalOpen(true);
};
const handleDelete = async (record: VitalSigns) => {
try {
await healthDataApi.deleteVitalSigns(patientId, record.id);
message.success('删除成功');
refresh();
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, '删除失败');
}
};
const handleSubmit = async (values: {
record_date: Dayjs;
systolic_bp_morning?: number;
diastolic_bp_morning?: number;
heart_rate?: number;
weight?: number;
blood_sugar?: number;
water_intake_ml?: number;
urine_output_ml?: number;
notes?: string;
}) => {
setSubmitting(true);
try {
const payload = {
record_date: values.record_date.format('YYYY-MM-DD'),
systolic_bp_morning: values.systolic_bp_morning,
diastolic_bp_morning: values.diastolic_bp_morning,
heart_rate: values.heart_rate,
weight: values.weight,
blood_sugar: values.blood_sugar,
water_intake_ml: values.water_intake_ml,
urine_output_ml: values.urine_output_ml,
notes: values.notes,
};
if (editingRecord) {
await healthDataApi.updateVitalSigns(patientId, editingRecord.id, {
...payload,
version: editingRecord.version,
});
message.success('体征数据更新成功');
} else {
await healthDataApi.createVitalSigns(patientId, payload);
message.success('体征数据录入成功');
}
setModalOpen(false);
form.resetFields();
setEditingRecord(null);
refresh();
setChartRefreshKey((k) => k + 1);
} catch (err) {
handleApiError(err, editingRecord ? '更新失败' : '录入失败');
} finally {
setSubmitting(false);
}
};
const columns = useMemo(
() => [
{
title: '记录日期',
dataIndex: 'record_date',
key: 'record_date',
width: 110,
fixed: 'left' as const,
},
{
title: () => (
<Tooltip title="晨间收缩压">
<span>()</span>
</Tooltip>
),
dataIndex: 'systolic_bp_morning',
key: 'systolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: () => (
<Tooltip title="晨间舒张压">
<span>()</span>
</Tooltip>
),
dataIndex: 'diastolic_bp_morning',
key: 'diastolic_bp_morning',
width: 110,
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
},
{
title: '心率',
dataIndex: 'heart_rate',
key: 'heart_rate',
width: 80,
render: (v?: number) => (v != null ? `${v} bpm` : '-'),
},
{
title: '体重',
dataIndex: 'weight',
key: 'weight',
width: 80,
render: (v?: number) => (v != null ? `${v} kg` : '-'),
},
{
title: '血糖',
dataIndex: 'blood_sugar',
key: 'blood_sugar',
width: 90,
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right' as const,
render: (_: unknown, record: VitalSigns) => (
<AuthButton code="health.health-data.manage">
<Space size={0}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleOpenEdit(record)}
/>
<Popconfirm
title="确认删除"
description="删除后无法恢复,确定要删除这条体征记录吗?"
onConfirm={() => handleDelete(record)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
</AuthButton>
),
},
],
// handleOpenEdit and handleDelete are stable closures that only depend on patientId via refresh
// eslint-disable-next-line react-hooks/exhaustive-deps
[patientId],
);
// 最近一次数据摘要
const latest = data.length > 0 ? data[0] : null;
return (
<div>
{/* 趋势图 */}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 8 }}>
<AuthButton code="ai.analysis.manage">
<Button
icon={<ThunderboltOutlined />}
loading={analyzingTrend}
onClick={handleTrendAnalysis}
size="small"
>
AI
</Button>
</AuthButton>
</div>
<VitalSignsChart patientId={patientId} refreshKey={chartRefreshKey} />
</div>
{/* AI 趋势分析结果 */}
{trendContent && (
<Card
title={<><ThunderboltOutlined /> AI </>}
size="small"
style={{ marginBottom: 12 }}
extra={
<Button size="small" onClick={() => setTrendContent('')}></Button>
}
>
<div style={{ whiteSpace: 'pre-wrap', lineHeight: 1.8 }}>
{trendContent}
</div>
</Card>
)}
{/* 最新记录摘要条 */}
{latest && (
<div style={{
display: 'flex',
gap: 16,
marginBottom: 12,
padding: '6px 12px',
background: 'var(--ant-color-bg-container, #fafafa)',
borderRadius: 8,
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
flexWrap: 'wrap',
}}>
<Text type="secondary" style={{ fontSize: 12, lineHeight: '20px' }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
{latest.record_date}
</Text>
{latest.systolic_bp_morning != null && (
<Text style={{ fontSize: 13 }}> {latest.systolic_bp_morning} mmHg</Text>
)}
{latest.diastolic_bp_morning != null && (
<Text style={{ fontSize: 13 }}> {latest.diastolic_bp_morning} mmHg</Text>
)}
{latest.heart_rate != null && (
<Text style={{ fontSize: 13 }}> {latest.heart_rate} bpm</Text>
)}
{latest.weight != null && (
<Text style={{ fontSize: 13 }}> {latest.weight} kg</Text>
)}
{latest.blood_sugar != null && (
<Text style={{ fontSize: 13 }}> {latest.blood_sugar} mmol/L</Text>
)}
</div>
)}
{/* 操作栏 + 表格 */}
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 13 }}>
{total}
</Text>
<Button
icon={<PlusOutlined />}
onClick={handleOpenCreate}
size="small"
>
</Button>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: page,
total,
pageSize: 10,
onChange: (p) => refresh(p),
showTotal: (t) => `${t}`,
size: 'small',
style: { margin: 0 },
}}
scroll={{ x: 700 }}
/>
<Modal
title={editingRecord ? '编辑体征数据' : '录入体征数据'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditingRecord(null);
}}
onOk={() => form.submit()}
confirmLoading={submitting}
destroyOnHidden
width={600}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
<Form.Item name="systolic_bp_morning" label="收缩压(晨) mmHg">
<InputNumber min={50} max={300} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="diastolic_bp_morning" label="舒张压(晨) mmHg">
<InputNumber min={30} max={200} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="heart_rate" label="心率 bpm">
<InputNumber min={30} max={250} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="weight" label="体重 kg">
<InputNumber min={1} max={500} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="blood_sugar" label="血糖 mmol/L">
<InputNumber min={0} max={50} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="water_intake_ml" label="饮水量 ml">
<InputNumber min={0} max={10000} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
}