feat(web): 药物记录 Web UI — Phase 2a-3
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

新增药物记录管理前端页面,接入后端 4 条孤立路由:
- API 模块: medicationRecords.ts(CRUD + 频次/途径常量)
- 列表页: MedicationRecordList.tsx(患者 ID 查询 + 药物列表 CRUD)
  支持药品名/通用名/剂量/频次/途径/日期/在用状态
- 路由注册: /health/medications

权限: health.medication-records.list / health.medication-records.manage
This commit is contained in:
iven
2026-05-04 23:41:04 +08:00
parent 68ced2bae9
commit 438f9ca3f4
4 changed files with 418 additions and 0 deletions

View File

@@ -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 />} />

View 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 } });
},
};

View File

@@ -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 {

View 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>
);
}