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 ShiftList = lazy(() => import('./pages/health/ShiftList'));
|
||||
const ShiftDetail = lazy(() => import('./pages/health/ShiftDetail'));
|
||||
const MedicationRecordList = lazy(() => import('./pages/health/MedicationRecordList'));
|
||||
|
||||
// 内容管理
|
||||
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/shifts" element={<ShiftList />} />
|
||||
<Route path="/health/shifts/:id" element={<ShiftDetail />} />
|
||||
<Route path="/health/medications" element={<MedicationRecordList />} />
|
||||
{/* 内容管理 */}
|
||||
<Route path="/health/articles" element={<ArticleManageList />} />
|
||||
<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/shifts': '班次管理',
|
||||
'/health/shifts/:id': '班次详情',
|
||||
'/health/medications': '药物记录',
|
||||
};
|
||||
|
||||
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