- 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>
368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|