Files
hms/apps/web/src/pages/health/components/VitalSignsTab.tsx
iven 4cfbdec5fc
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
refactor(web): 统一 dayjs 导入为集中初始化 — 11 个文件
所有 health 页面从 import dayjs from 'dayjs' 迁移到
import { dayjs } from '.../utils/dayjs',确保 relativeTime
和 zh-cn locale 全局生效。
2026-04-28 01:47:13 +08:00

327 lines
11 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 } from 'antd';
import { PlusOutlined, InfoCircleOutlined, EditOutlined, DeleteOutlined } 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';
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 [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 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 }}>
<VitalSignsChart patientId={patientId} refreshKey={chartRefreshKey} />
</div>
{/* 最新记录摘要条 */}
{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}
destroyOnClose
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>
);
}