feat(health): Phase 4 跨模块集成与架构优化 — 通知/标签/待办/数据录入
后端: - erp-message: 添加 appointment.created/confirmed/cancelled 事件监听,自动发送站内通知 - erp-health: 新增 GET /health/patient-tags 标签列表端点 + list_tags service - wechat-templates: 添加 isTemplateConfigured 运行时校验 前端: - 新增 Zustand useHealthStore 共享患者/医生名称缓存 - PatientTagManage: UUID 输入替换为 Checkbox 标签选择器 - VitalSignsTab: 添加体征数据录入 Modal (血压/心率/体重/血糖) - LabReportsTab: 添加化验报告创建 Modal - HealthRecordsTab: 添加健康记录创建 Modal - patients API: 添加 TagItem 类型 + listTags 方法 小程序: - 首页待办事项接入预约和随访 API,替换硬编码 EmptyState
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { HealthRecord } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
@@ -9,69 +10,91 @@ interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<HealthRecord> = [
|
||||
{
|
||||
title: '记录类型',
|
||||
dataIndex: 'record_type',
|
||||
key: 'record_type',
|
||||
width: 120,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
key: 'content',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
const columns = [
|
||||
{ title: '记录类型', dataIndex: 'record_type', key: 'record_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
];
|
||||
|
||||
/**
|
||||
* 健康档案标签页 — 分页表格
|
||||
*/
|
||||
export function HealthRecordsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listHealthRecords(patientId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return healthDataApi.listHealthRecords(patientId, { page, page_size: pageSize });
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<HealthRecord>(fetcher, 10);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
record_type: string;
|
||||
record_date: Dayjs;
|
||||
content?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await healthDataApi.createHealthRecord(patientId, {
|
||||
record_type: values.record_type,
|
||||
record_date: values.record_date.format('YYYY-MM-DD'),
|
||||
content: values.content,
|
||||
});
|
||||
message.success('健康记录添加成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
添加记录
|
||||
</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} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加健康记录"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="record_type" label="记录类型" rules={[{ required: true, message: '请输入类型' }]}>
|
||||
<Input placeholder="如:门诊记录、出院小结、体检报告" />
|
||||
</Form.Item>
|
||||
<Form.Item name="record_date" label="记录日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="内容">
|
||||
<Input.TextArea rows={4} placeholder="健康记录详细内容" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table, Tag } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Tag, Button, Modal, Form, Input, DatePicker, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { LabReport } from '../../../api/health/healthData';
|
||||
import { usePaginatedData } from '../../../hooks/usePaginatedData';
|
||||
@@ -9,69 +10,91 @@ interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<LabReport> = [
|
||||
{
|
||||
title: '报告日期',
|
||||
dataIndex: 'report_date',
|
||||
key: 'report_date',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '报告类型',
|
||||
dataIndex: 'report_type',
|
||||
key: 'report_type',
|
||||
width: 120,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '医生解读',
|
||||
dataIndex: 'doctor_interpretation',
|
||||
key: 'doctor_interpretation',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'created_at',
|
||||
key: 'created_at',
|
||||
width: 170,
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
const columns = [
|
||||
{ title: '报告日期', dataIndex: 'report_date', key: 'report_date', width: 120 },
|
||||
{ title: '报告类型', dataIndex: 'report_type', key: 'report_type', width: 120, render: (v: string) => <Tag>{v}</Tag> },
|
||||
{ title: '医生解读', dataIndex: 'doctor_interpretation', key: 'doctor_interpretation', ellipsis: true },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: (v: string) => new Date(v).toLocaleString('zh-CN') },
|
||||
];
|
||||
|
||||
/**
|
||||
* 化验报告标签页 — 分页表格
|
||||
*/
|
||||
export function LabReportsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetcher = useCallback(
|
||||
async (page: number, pageSize: number) => {
|
||||
return healthDataApi.listLabReports(patientId, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return healthDataApi.listLabReports(patientId, { page, page_size: pageSize });
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<LabReport>(fetcher, 10);
|
||||
|
||||
const handleCreate = async (values: {
|
||||
report_date: Dayjs;
|
||||
report_type: string;
|
||||
doctor_interpretation?: string;
|
||||
}) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await healthDataApi.createLabReport(patientId, {
|
||||
report_date: values.report_date.format('YYYY-MM-DD'),
|
||||
report_type: values.report_type,
|
||||
doctor_interpretation: values.doctor_interpretation,
|
||||
});
|
||||
message.success('化验报告添加成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('添加失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
添加报告
|
||||
</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} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="添加化验报告"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={520}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<Form.Item name="report_date" label="报告日期" rules={[{ required: true, message: '请选择日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="report_type" label="报告类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Input placeholder="如:血常规、生化全套" />
|
||||
</Form.Item>
|
||||
<Form.Item name="doctor_interpretation" label="医生解读">
|
||||
<Input.TextArea rows={3} placeholder="检查结果解读备注" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Table, Button, Modal, Form, InputNumber, DatePicker, Input, message } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { healthDataApi } from '../../../api/health/healthData';
|
||||
import type { VitalSigns } from '../../../api/health/healthData';
|
||||
import { VitalSignsChart } from './VitalSignsChart';
|
||||
@@ -10,71 +11,71 @@ interface Props {
|
||||
patientId: string;
|
||||
}
|
||||
|
||||
const columns: ColumnsType<VitalSigns> = [
|
||||
{
|
||||
title: '记录日期',
|
||||
dataIndex: 'record_date',
|
||||
key: 'record_date',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: '收缩压(晨)',
|
||||
dataIndex: 'systolic_bp_morning',
|
||||
key: 'systolic_bp_morning',
|
||||
width: 110,
|
||||
render: (v?: number) => (v != null ? `${v} mmHg` : '-'),
|
||||
},
|
||||
{
|
||||
title: '舒张压(晨)',
|
||||
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: 80,
|
||||
render: (v?: number) => (v != null ? `${v} mmol/L` : '-'),
|
||||
},
|
||||
const columns = [
|
||||
{ title: '记录日期', dataIndex: 'record_date', key: 'record_date', width: 120 },
|
||||
{ title: '收缩压(晨)', dataIndex: 'systolic_bp_morning', key: 'systolic_bp_morning', width: 110, render: (v?: number) => (v != null ? `${v} mmHg` : '-') },
|
||||
{ title: '舒张压(晨)', 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: 80, render: (v?: number) => (v != null ? `${v} mmol/L` : '-') },
|
||||
];
|
||||
|
||||
/**
|
||||
* 体征数据标签页 — 含趋势图 + 分页表格
|
||||
*/
|
||||
export function VitalSignsTab({ patientId }: Props) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
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,
|
||||
});
|
||||
return healthDataApi.listVitalSigns(patientId, { page, page_size: pageSize });
|
||||
},
|
||||
[patientId],
|
||||
);
|
||||
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(
|
||||
fetcher,
|
||||
10,
|
||||
);
|
||||
const { data, total, page, loading, refresh } = usePaginatedData<VitalSigns>(fetcher, 10);
|
||||
|
||||
const handleCreate = 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 {
|
||||
await healthDataApi.createVitalSigns(patientId, {
|
||||
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,
|
||||
});
|
||||
message.success('体征数据录入成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
refresh();
|
||||
} catch {
|
||||
message.error('录入失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true); }}>
|
||||
录入体征
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<VitalSignsChart patientId={patientId} />
|
||||
</div>
|
||||
@@ -85,14 +86,50 @@ export function VitalSignsTab({ patientId }: Props) {
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
pageSize: 10,
|
||||
current: page, total, pageSize: 10,
|
||||
onChange: (p) => refresh(p),
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
style: { margin: 0 },
|
||||
}}
|
||||
/>
|
||||
<Modal
|
||||
title="录入体征数据"
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleCreate}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user