feat(web): 药物记录 Web UI — Phase 2a-3
新增药物记录管理前端页面,接入后端 4 条孤立路由: - API 模块: medicationRecords.ts(CRUD + 频次/途径常量) - 列表页: MedicationRecordList.tsx(患者 ID 查询 + 药物列表 CRUD) 支持药品名/通用名/剂量/频次/途径/日期/在用状态 - 路由注册: /health/medications 权限: health.medication-records.list / health.medication-records.manage
This commit is contained in:
@@ -57,6 +57,7 @@ const CarePlanList = lazy(() => import('./pages/health/CarePlanList'));
|
|||||||
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
const CarePlanDetail = lazy(() => import('./pages/health/CarePlanDetail'));
|
||||||
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
const ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
||||||
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
||||||
|
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
|
||||||
|
|
||||||
// 内容管理
|
// 内容管理
|
||||||
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
const ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
|
||||||
@@ -279,6 +280,7 @@ export default function App() {
|
|||||||
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
<Route path="/health/care-plans/:id" element={<CarePlanDetail />} />
|
||||||
<Route path="/health/shifts" element={<ShiftList />} />
|
<Route path="/health/shifts" element={<ShiftList />} />
|
||||||
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
||||||
|
<Route path="/health/medications" element={<MedicationRecordList />} />
|
||||||
{/* 内容管理 */}
|
{/* 内容管理 */}
|
||||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||||
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
<Route path="/health/articles/new" element={<ArticleEditor />} />
|
||||||
|
|||||||
111
apps/web/src/api/health/medicationRecords.ts
Normal file
111
apps/web/src/api/health/medicationRecords.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import client from '../client';
|
||||||
|
import type { PaginatedResponse } from '../types';
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface MedicationRecord {
|
||||||
|
id: string;
|
||||||
|
patient_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
generic_name?: string;
|
||||||
|
dosage?: string;
|
||||||
|
unit?: string;
|
||||||
|
frequency?: string;
|
||||||
|
route?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_current: boolean;
|
||||||
|
prescribed_by?: string;
|
||||||
|
notes?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMedicationRecordReq {
|
||||||
|
patient_id: string;
|
||||||
|
medication_name: string;
|
||||||
|
generic_name?: string;
|
||||||
|
dosage?: string;
|
||||||
|
unit?: string;
|
||||||
|
frequency?: string;
|
||||||
|
route?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_current?: boolean;
|
||||||
|
prescribed_by?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMedicationRecordReq {
|
||||||
|
medication_name?: string;
|
||||||
|
generic_name?: string;
|
||||||
|
dosage?: string;
|
||||||
|
unit?: string;
|
||||||
|
frequency?: string;
|
||||||
|
route?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
is_current?: boolean;
|
||||||
|
prescribed_by?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Constants ---
|
||||||
|
|
||||||
|
export const FREQUENCY_OPTIONS = [
|
||||||
|
{ label: '每日一次', value: 'QD' },
|
||||||
|
{ label: '每日两次', value: 'BID' },
|
||||||
|
{ label: '每日三次', value: 'TID' },
|
||||||
|
{ label: '每晚一次', value: 'QN' },
|
||||||
|
{ label: '每周一次', value: 'QW' },
|
||||||
|
{ label: '必要时', value: 'PRN' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROUTE_OPTIONS = [
|
||||||
|
{ label: '口服', value: 'oral' },
|
||||||
|
{ label: '静脉注射', value: 'iv' },
|
||||||
|
{ label: '皮下注射', value: 'sc' },
|
||||||
|
{ label: '外用', value: 'topical' },
|
||||||
|
{ label: '吸入', value: 'inhalation' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
|
||||||
|
export const medicationRecordApi = {
|
||||||
|
list: async (patientId: string, params?: { page?: number; page_size?: number }) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: PaginatedResponse<MedicationRecord>;
|
||||||
|
}>(`/health/patients/${patientId}/medications`, { params });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
get: async (id: string) => {
|
||||||
|
const { data } = await client.get<{
|
||||||
|
success: boolean;
|
||||||
|
data: MedicationRecord;
|
||||||
|
}>(`/health/medications/${id}`);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (req: CreateMedicationRecordReq) => {
|
||||||
|
const { data } = await client.post<{
|
||||||
|
success: boolean;
|
||||||
|
data: MedicationRecord;
|
||||||
|
}>('/health/medications', req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
update: async (id: string, req: UpdateMedicationRecordReq & { version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: MedicationRecord;
|
||||||
|
}>(`/health/medications/${id}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string, version: number) => {
|
||||||
|
await client.delete(`/health/medications/${id}`, { data: { version } });
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -115,6 +115,7 @@ const routeTitleFallback: Record<string, string> = {
|
|||||||
'/health/care-plans/:id': '护理计划详情',
|
'/health/care-plans/:id': '护理计划详情',
|
||||||
'/health/shifts': '班次管理',
|
'/health/shifts': '班次管理',
|
||||||
'/health/shifts/:id': '班次详情',
|
'/health/shifts/:id': '班次详情',
|
||||||
|
'/health/medications': '药物记录',
|
||||||
};
|
};
|
||||||
|
|
||||||
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
|
||||||
|
|||||||
304
apps/web/src/pages/health/MedicationRecordList.tsx
Normal file
304
apps/web/src/pages/health/MedicationRecordList.tsx
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Button, DatePicker, Form, Input, message, Modal, Popconfirm,
|
||||||
|
Result, Select, Space, Switch, Table, Tag,
|
||||||
|
} from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
medicationRecordApi,
|
||||||
|
type MedicationRecord,
|
||||||
|
type CreateMedicationRecordReq,
|
||||||
|
type UpdateMedicationRecordReq,
|
||||||
|
FREQUENCY_OPTIONS,
|
||||||
|
ROUTE_OPTIONS,
|
||||||
|
} from '../../api/health/medicationRecords';
|
||||||
|
import { PageContainer } from '../../components/PageContainer';
|
||||||
|
import { usePermission } from '../../hooks/usePermission';
|
||||||
|
|
||||||
|
export default function MedicationRecordList() {
|
||||||
|
const { hasPermission } = usePermission('health.medication-records.manage');
|
||||||
|
|
||||||
|
const [data, setData] = useState<MedicationRecord[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [patientId, setPatientId] = useState('');
|
||||||
|
const [searchInput, setSearchInput] = useState('');
|
||||||
|
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editRecord, setEditRecord] = useState<MedicationRecord | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const pageSize = 20;
|
||||||
|
|
||||||
|
const fetchData = useCallback(async (pid: string, p: number) => {
|
||||||
|
if (!pid) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await medicationRecordApi.list(pid, { page: p, page_size: pageSize });
|
||||||
|
setData(resp.data);
|
||||||
|
setTotal(resp.total);
|
||||||
|
setPage(p);
|
||||||
|
} catch {
|
||||||
|
message.error('加载药物记录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
const pid = searchInput.trim();
|
||||||
|
if (!pid) {
|
||||||
|
message.warning('请输入患者 ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPatientId(pid);
|
||||||
|
fetchData(pid, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditRecord(null);
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({ patient_id: patientId, is_current: true });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (record: MedicationRecord) => {
|
||||||
|
setEditRecord(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
medication_name: record.medication_name,
|
||||||
|
generic_name: record.generic_name,
|
||||||
|
dosage: record.dosage,
|
||||||
|
unit: record.unit,
|
||||||
|
frequency: record.frequency,
|
||||||
|
route: record.route,
|
||||||
|
start_date: record.start_date ? dayjs(record.start_date) : undefined,
|
||||||
|
end_date: record.end_date ? dayjs(record.end_date) : undefined,
|
||||||
|
is_current: record.is_current,
|
||||||
|
prescribed_by: record.prescribed_by,
|
||||||
|
notes: record.notes,
|
||||||
|
});
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const req = {
|
||||||
|
...values,
|
||||||
|
start_date: values.start_date?.format('YYYY-MM-DD'),
|
||||||
|
end_date: values.end_date?.format('YYYY-MM-DD'),
|
||||||
|
};
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
if (editRecord) {
|
||||||
|
await medicationRecordApi.update(editRecord.id, {
|
||||||
|
...req,
|
||||||
|
version: editRecord.version,
|
||||||
|
} as UpdateMedicationRecordReq & { version: number });
|
||||||
|
message.success('药物记录已更新');
|
||||||
|
} else {
|
||||||
|
await medicationRecordApi.create(req as CreateMedicationRecordReq);
|
||||||
|
message.success('药物记录已创建');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchData(patientId, page);
|
||||||
|
} catch {
|
||||||
|
// validation
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (record: MedicationRecord) => {
|
||||||
|
try {
|
||||||
|
await medicationRecordApi.delete(record.id, record.version);
|
||||||
|
message.success('药物记录已删除');
|
||||||
|
fetchData(patientId, page);
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnsType<MedicationRecord> = useMemo(() => [
|
||||||
|
{
|
||||||
|
title: '药品名称',
|
||||||
|
dataIndex: 'medication_name',
|
||||||
|
width: 160,
|
||||||
|
render: (v: string, record) => (
|
||||||
|
<span>
|
||||||
|
<strong>{v}</strong>
|
||||||
|
{record.generic_name && <span style={{ color: '#999', marginLeft: 8 }}>({record.generic_name})</span>}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '剂量',
|
||||||
|
width: 120,
|
||||||
|
render: (_, record) => {
|
||||||
|
if (!record.dosage) return '-';
|
||||||
|
return `${record.dosage}${record.unit ? ' ' + record.unit : ''}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '频次',
|
||||||
|
dataIndex: 'frequency',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => {
|
||||||
|
const label = FREQUENCY_OPTIONS.find((o) => o.value === v)?.label;
|
||||||
|
return label ?? v ?? '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '给药途径',
|
||||||
|
dataIndex: 'route',
|
||||||
|
width: 100,
|
||||||
|
render: (v: string) => {
|
||||||
|
const label = ROUTE_OPTIONS.find((o) => o.value === v)?.label;
|
||||||
|
return label ?? v ?? '-';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '开始日期',
|
||||||
|
dataIndex: 'start_date',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '结束日期',
|
||||||
|
dataIndex: 'end_date',
|
||||||
|
width: 110,
|
||||||
|
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD') : '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: 'is_current',
|
||||||
|
width: 80,
|
||||||
|
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '在用' : '停用'}</Tag>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'notes',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v: string) => v ?? '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => handleEdit(record)}>编辑</Button>
|
||||||
|
<Popconfirm title="确定删除此药物记录?" onConfirm={() => handleDelete(record)}>
|
||||||
|
<Button size="small" danger>删除</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [patientId, page]);
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
return <Result status="403" title="权限不足" subTitle="您没有管理药物记录的权限" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
title="药物记录"
|
||||||
|
actions={patientId ? <Button type="primary" onClick={handleCreate}>添加药物</Button> : undefined}
|
||||||
|
>
|
||||||
|
<Space style={{ marginBottom: 16 }} wrap>
|
||||||
|
<Input.Search
|
||||||
|
placeholder="输入患者 ID 搜索"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
style={{ width: 360 }}
|
||||||
|
enterButton="查询"
|
||||||
|
/>
|
||||||
|
{patientId && (
|
||||||
|
<Tag color="blue">当前患者: {patientId}</Tag>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
{!patientId ? (
|
||||||
|
<Result title="请输入患者 ID 查询药物记录" />
|
||||||
|
) : (
|
||||||
|
<Table<MedicationRecord>
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
showTotal: (t) => `共 ${t} 条`,
|
||||||
|
onChange: (p) => fetchData(patientId, p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editRecord ? '编辑药物' : '添加药物'}
|
||||||
|
open={modalOpen}
|
||||||
|
onOk={handleSubmit}
|
||||||
|
onCancel={() => setModalOpen(false)}
|
||||||
|
confirmLoading={submitting}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical">
|
||||||
|
{!editRecord && (
|
||||||
|
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true }]}>
|
||||||
|
<Input disabled />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
<Space style={{ width: '100%' }} size="middle">
|
||||||
|
<Form.Item name="medication_name" label="药品名称" rules={[{ required: true, message: '请输入药品名' }]} style={{ width: 260 }}>
|
||||||
|
<Input placeholder="如:碳酸钙" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="generic_name" label="通用名" style={{ width: 260 }}>
|
||||||
|
<Input placeholder="如:碳酸钙片" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space style={{ width: '100%' }} size="middle">
|
||||||
|
<Form.Item name="dosage" label="剂量" style={{ width: 170 }}>
|
||||||
|
<Input placeholder="如:600" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="unit" label="单位" style={{ width: 120 }}>
|
||||||
|
<Input placeholder="如:mg" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="frequency" label="频次" style={{ width: 200 }}>
|
||||||
|
<Select options={FREQUENCY_OPTIONS} placeholder="选择频次" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space style={{ width: '100%' }} size="middle">
|
||||||
|
<Form.Item name="route" label="给药途径" style={{ width: 200 }}>
|
||||||
|
<Select options={ROUTE_OPTIONS} placeholder="选择途径" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="prescribed_by" label="处方医生" style={{ width: 260 }}>
|
||||||
|
<Input placeholder="医生姓名或 ID" />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Space style={{ width: '100%' }} size="middle">
|
||||||
|
<Form.Item name="start_date" label="开始日期">
|
||||||
|
<DatePicker style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="end_date" label="结束日期">
|
||||||
|
<DatePicker style={{ width: 220 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item name="is_current" label="当前在用" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="notes" label="备注">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user