feat(web): 班次管理 Web UI — Phase 2a-2
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

新增班次管理前端页面,接入后端 12 条孤立路由:
- API 模块: shifts.ts(班次 CRUD + 患者分配 + 批量分配 + 交接日志)
- 列表页: ShiftList.tsx(日期/班次/状态筛选 + 统计概览)
- 详情页: ShiftDetail.tsx(班次信息 + 患者分配 Tab + 交接记录 Tab)
- 路由注册: /health/shifts + /health/shifts/:id

权限: health.shifts.list / health.shifts.manage
This commit is contained in:
iven
2026-05-04 23:36:15 +08:00
parent 3aa436f872
commit 68ced2bae9
5 changed files with 834 additions and 0 deletions

View File

@@ -0,0 +1,306 @@
import { useState, useCallback, useEffect } from 'react';
import {
Button, Descriptions, Form, Input, message, Modal, Popconfirm,
Result, Select, Space, Spin, Table, Tabs, Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useParams, useNavigate } from 'react-router-dom';
import {
shiftApi,
type Shift,
type PatientAssignment,
type HandoffLog,
type CreatePatientAssignmentReq,
type CreateHandoffReq,
PERIOD_LABEL,
SHIFT_STATUS_LABEL,
SHIFT_STATUS_COLOR,
CARE_LEVEL_OPTIONS,
CARE_LEVEL_LABEL,
CARE_LEVEL_COLOR,
} from '../../api/health/shifts';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
export default function ShiftDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { hasPermission } = usePermission('health.shifts.manage');
const [shift, setShift] = useState<Shift | null>(null);
const [loading, setLoading] = useState(true);
// Assignments
const [assignments, setAssignments] = useState<PatientAssignment[]>([]);
const [assignLoading, setAssignLoading] = useState(false);
const [assignModalOpen, setAssignModalOpen] = useState(false);
const [editAssignment, setEditAssignment] = useState<PatientAssignment | null>(null);
const [assignForm] = Form.useForm();
// Handoff logs
const [handoffs, setHandoffs] = useState<HandoffLog[]>([]);
const [handoffLoading, setHandoffLoading] = useState(false);
const [handoffModalOpen, setHandoffModalOpen] = useState(false);
const [handoffForm] = Form.useForm();
const shiftId = id!;
const fetchShift = useCallback(async () => {
setLoading(true);
try {
const data = await shiftApi.get(shiftId);
setShift(data);
} catch {
message.error('加载班次详情失败');
} finally {
setLoading(false);
}
}, [shiftId]);
const fetchAssignments = useCallback(async () => {
setAssignLoading(true);
try {
const resp = await shiftApi.listAssignments(shiftId, { page: 1, page_size: 200 });
setAssignments(resp.data);
} catch {
message.error('加载患者分配失败');
} finally {
setAssignLoading(false);
}
}, [shiftId]);
const fetchHandoffs = useCallback(async () => {
setHandoffLoading(true);
try {
const resp = await shiftApi.listHandoffs({ from_shift_id: shiftId });
setHandoffs(resp.data);
} catch {
message.error('加载交接记录失败');
} finally {
setHandoffLoading(false);
}
}, [shiftId]);
useEffect(() => {
fetchShift();
fetchAssignments();
fetchHandoffs();
}, [fetchShift, fetchAssignments, fetchHandoffs]);
// --- Assignment CRUD ---
const handleAddAssignment = () => {
setEditAssignment(null);
assignForm.resetFields();
setAssignModalOpen(true);
};
const handleEditAssignment = (record: PatientAssignment) => {
setEditAssignment(record);
assignForm.setFieldsValue({
patient_id: record.patient_id,
care_level: record.care_level,
notes: record.notes,
});
setAssignModalOpen(true);
};
const handleSubmitAssignment = async () => {
try {
const values = await assignForm.validateFields();
if (editAssignment) {
await shiftApi.updateAssignment(shiftId, editAssignment.id, {
care_level: values.care_level,
notes: values.notes,
version: editAssignment.version,
});
message.success('分配已更新');
} else {
await shiftApi.createAssignment(shiftId, values as CreatePatientAssignmentReq);
message.success('患者已分配');
}
setAssignModalOpen(false);
fetchAssignments();
fetchShift();
} catch {
// validation
}
};
const handleRemoveAssignment = async (record: PatientAssignment) => {
try {
await shiftApi.deleteAssignment(shiftId, record.id, record.version);
message.success('已移除分配');
fetchAssignments();
fetchShift();
} catch {
message.error('移除失败');
}
};
// --- Handoff ---
const handleCreateHandoff = () => {
handoffForm.resetFields();
handoffForm.setFieldsValue({ from_shift_id: shiftId });
setHandoffModalOpen(true);
};
const handleSubmitHandoff = async () => {
try {
const values = await handoffForm.validateFields();
await shiftApi.createHandoff(values as CreateHandoffReq);
message.success('交接记录已创建');
setHandoffModalOpen(false);
fetchHandoffs();
} catch {
// validation
}
};
// --- Columns ---
const assignColumns: ColumnsType<PatientAssignment> = [
{ title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true },
{
title: '护理等级',
dataIndex: 'care_level',
width: 120,
render: (v: string) => <Tag color={CARE_LEVEL_COLOR[v] ?? 'default'}>{CARE_LEVEL_LABEL[v] ?? v}</Tag>,
},
{ title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' },
{
title: '操作', width: 140, render: (_, record) => hasPermission ? (
<Space>
<Button size="small" onClick={() => handleEditAssignment(record)}></Button>
<Popconfirm title="确定移除此患者?" onConfirm={() => handleRemoveAssignment(record)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
) : null,
},
];
const handoffColumns: ColumnsType<HandoffLog> = [
{ title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true },
{ title: '目标班次', dataIndex: 'to_shift_id', width: 280, ellipsis: true },
{ title: '交接备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' },
{
title: '交接时间',
dataIndex: 'created_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
];
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!shift) return <Result status="404" title="班次不存在" />;
if (!hasPermission) return <Result status="403" title="权限不足" />;
return (
<PageContainer
title={`${dayjs(shift.shift_date).format('YYYY-MM-DD')} ${PERIOD_LABEL[shift.period] ?? shift.period}`}
onBack={() => navigate('/health/shifts')}
>
<Descriptions bordered size="small" column={3} style={{ marginBottom: 24 }}>
<Descriptions.Item label="状态">
<Tag color={SHIFT_STATUS_COLOR[shift.status] ?? 'default'}>{SHIFT_STATUS_LABEL[shift.status] ?? shift.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="患者数">{shift.patient_count ?? assignments.length}</Descriptions.Item>
<Descriptions.Item label="责任护士">{shift.nurse_id ?? '-'}</Descriptions.Item>
{shift.notes && <Descriptions.Item label="备注" span={3}>{shift.notes}</Descriptions.Item>}
</Descriptions>
<Tabs
defaultActiveKey="assignments"
items={[
{
key: 'assignments',
label: `患者分配 (${assignments.length})`,
children: (
<>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" size="small" onClick={handleAddAssignment}></Button>
</Space>
<Table<PatientAssignment>
rowKey="id"
columns={assignColumns}
dataSource={assignments}
loading={assignLoading}
pagination={false}
size="small"
/>
</>
),
},
{
key: 'handoffs',
label: `交接记录 (${handoffs.length})`,
children: (
<>
<Button type="primary" size="small" style={{ marginBottom: 12 }} onClick={handleCreateHandoff}>
</Button>
<Table<HandoffLog>
rowKey="id"
columns={handoffColumns}
dataSource={handoffs}
loading={handoffLoading}
pagination={false}
size="small"
/>
</>
),
},
]}
/>
{/* Assignment Modal */}
<Modal
title={editAssignment ? '编辑分配' : '分配患者'}
open={assignModalOpen}
onOk={handleSubmitAssignment}
onCancel={() => setAssignModalOpen(false)}
width={480}
>
<Form form={assignForm} layout="vertical">
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true, message: '请输入患者 ID' }]}>
<Input placeholder="患者 UUID" disabled={!!editAssignment} />
</Form.Item>
<Form.Item name="care_level" label="护理等级">
<Select options={CARE_LEVEL_OPTIONS} placeholder="选择等级" allowClear />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* Handoff Modal */}
<Modal
title="新建交接记录"
open={handoffModalOpen}
onOk={handleSubmitHandoff}
onCancel={() => setHandoffModalOpen(false)}
width={520}
>
<Form form={handoffForm} layout="vertical">
<Form.Item name="from_shift_id" label="来源班次" rules={[{ required: true }]}>
<Input disabled />
</Form.Item>
<Form.Item name="to_shift_id" label="目标班次 ID" rules={[{ required: true, message: '请输入目标班次 ID' }]}>
<Input placeholder="目标班次 UUID" />
</Form.Item>
<Form.Item name="patient_id" label="患者 ID" rules={[{ required: true, message: '请输入患者 ID' }]}>
<Input placeholder="患者 UUID" />
</Form.Item>
<Form.Item name="notes" label="交接备注">
<Input.TextArea rows={3} placeholder="需要交代的特殊事项" />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,275 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import {
Badge, Button, DatePicker, Form, Input, message, Modal, Popconfirm,
Result, Select, Space, Table, Tag,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import {
shiftApi,
type Shift,
type CreateShiftReq,
type UpdateShiftReq,
PERIOD_OPTIONS,
SHIFT_STATUS_OPTIONS,
PERIOD_LABEL,
SHIFT_STATUS_LABEL,
SHIFT_STATUS_COLOR,
} from '../../api/health/shifts';
import { PageContainer } from '../../components/PageContainer';
import { usePermission } from '../../hooks/usePermission';
interface FilterValues {
shift_date?: string;
period?: string;
status?: string;
}
export default function ShiftList() {
const { hasPermission } = usePermission('health.shifts.manage');
const navigate = useNavigate();
const [data, setData] = useState<Shift[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [filters, setFilters] = useState<FilterValues>({});
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Shift | null>(null);
const [submitting, setSubmitting] = useState(false);
const [form] = Form.useForm();
const pageSize = 20;
const fetchData = useCallback(async (p: number, f: FilterValues) => {
setLoading(true);
try {
const resp = await shiftApi.list({
page: p,
page_size: pageSize,
shift_date: f.shift_date || undefined,
period: f.period || undefined,
status: f.status || undefined,
});
setData(resp.data);
setTotal(resp.total);
setPage(p);
} catch {
message.error('加载班次列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData(1, filters);
}, [fetchData, filters]);
const handleFilterChange = (key: string, value: string | undefined) => {
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handleResetFilters = () => {
setFilters({});
};
const handleCreate = () => {
setEditRecord(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (record: Shift) => {
setEditRecord(record);
form.setFieldsValue({
shift_date: record.shift_date ? dayjs(record.shift_date) : undefined,
period: record.period,
nurse_id: record.nurse_id,
notes: record.notes,
});
setModalOpen(true);
};
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const req = {
shift_date: values.shift_date?.format('YYYY-MM-DD'),
period: values.period,
nurse_id: values.nurse_id || undefined,
notes: values.notes,
...(editRecord ? { version: editRecord.version } : {}),
};
setSubmitting(true);
if (editRecord) {
await shiftApi.update(editRecord.id, req as UpdateShiftReq & { version: number });
message.success('班次已更新');
} else {
await shiftApi.create(req as CreateShiftReq);
message.success('班次已创建');
}
setModalOpen(false);
fetchData(page, filters);
} catch {
// validation
} finally {
setSubmitting(false);
}
};
const handleDelete = async (record: Shift) => {
try {
await shiftApi.delete(record.id, record.version);
message.success('班次已删除');
fetchData(page, filters);
} catch {
message.error('删除失败');
}
};
const columns: ColumnsType<Shift> = useMemo(() => [
{
title: '日期',
dataIndex: 'shift_date',
width: 120,
render: (v: string) => dayjs(v).format('YYYY-MM-DD'),
},
{
title: '班次',
dataIndex: 'period',
width: 100,
render: (v: string) => <Tag>{PERIOD_LABEL[v] ?? v}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => (
<Tag color={SHIFT_STATUS_COLOR[v] ?? 'default'}>{SHIFT_STATUS_LABEL[v] ?? v}</Tag>
),
},
{
title: '患者数',
dataIndex: 'patient_count',
width: 80,
render: (v: number) => v ?? 0,
},
{
title: '危重',
dataIndex: 'critical_count',
width: 70,
render: (v: number) => v ? <Tag color="red">{v}</Tag> : 0,
},
{
title: '需关注',
dataIndex: 'attention_count',
width: 70,
render: (v: number) => v ? <Tag color="orange">{v}</Tag> : 0,
},
{
title: '备注',
dataIndex: 'notes',
width: 160,
ellipsis: true,
render: (v: string) => v ?? '-',
},
{
title: '更新时间',
dataIndex: 'updated_at',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
width: 200,
render: (_, record) => (
<Space>
<Button size="small" type="link" onClick={() => navigate(`/health/shifts/${record.id}`)}></Button>
<Button size="small" onClick={() => handleEdit(record)}></Button>
<Popconfirm title="确定删除此班次?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
], [navigate, page, filters]);
if (!hasPermission) {
return <Result status="403" title="权限不足" subTitle="您没有管理班次的权限" />;
}
return (
<PageContainer
title="班次管理"
actions={<Button type="primary" onClick={handleCreate}></Button>}
>
<Space style={{ marginBottom: 16 }} wrap>
<DatePicker
placeholder="选择日期"
value={filters.shift_date ? dayjs(filters.shift_date) : undefined}
onChange={(_, ds) => handleFilterChange('shift_date', ds as string || undefined)}
style={{ width: 150 }}
/>
<Select
allowClear
placeholder="班次"
options={PERIOD_OPTIONS}
value={filters.period}
onChange={(v) => handleFilterChange('period', v)}
style={{ width: 120 }}
/>
<Select
allowClear
placeholder="状态"
options={SHIFT_STATUS_OPTIONS}
value={filters.status}
onChange={(v) => handleFilterChange('status', v)}
style={{ width: 120 }}
/>
<Button onClick={handleResetFilters}></Button>
</Space>
<Table<Shift>
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
pageSize,
total,
showTotal: (t) => `${t}`,
onChange: (p) => fetchData(p, filters),
}}
/>
<Modal
title={editRecord ? '编辑班次' : '新建班次'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
confirmLoading={submitting}
width={480}
>
<Form form={form} layout="vertical">
<Form.Item name="shift_date" label="日期" rules={[{ required: true, message: '请选择日期' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="班次" rules={[{ required: true, message: '请选择班次' }]}>
<Select options={PERIOD_OPTIONS} placeholder="选择班次" />
</Form.Item>
<Form.Item name="nurse_id" label="责任护士 ID">
<Input placeholder="护士 UUID可选" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</PageContainer>
);
}