diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 7367c6f..7262bba 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -55,6 +55,8 @@ const ActionInbox = lazy(() => import('./pages/health/ActionInbox'));
const FollowUpTemplateList = lazy(() => import('./pages/health/FollowUpTemplateList'));
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 ArticleManageList = lazy(() => import('./pages/health/ArticleManageList'));
@@ -275,6 +277,8 @@ export default function App() {
} />
} />
} />
+ } />
+ } />
{/* 内容管理 */}
} />
} />
diff --git a/apps/web/src/api/health/shifts.ts b/apps/web/src/api/health/shifts.ts
new file mode 100644
index 0000000..05f2fbe
--- /dev/null
+++ b/apps/web/src/api/health/shifts.ts
@@ -0,0 +1,247 @@
+import client from '../client';
+import type { PaginatedResponse } from '../types';
+
+// --- Types ---
+
+export interface Shift {
+ id: string;
+ tenant_id: string;
+ shift_date: string;
+ period: string;
+ nurse_id?: string;
+ status: string;
+ notes?: string;
+ created_at: string;
+ updated_at: string;
+ version: number;
+ patient_count?: number;
+ critical_count?: number;
+ attention_count?: number;
+}
+
+export interface PatientAssignment {
+ id: string;
+ tenant_id: string;
+ shift_id: string;
+ patient_id: string;
+ care_level: string;
+ notes?: string;
+ created_at: string;
+ updated_at: string;
+ version: number;
+ patient_name?: string;
+}
+
+export interface HandoffLog {
+ id: string;
+ tenant_id: string;
+ from_shift_id: string;
+ to_shift_id: string;
+ patient_id: string;
+ notes?: string;
+ pending_items?: Record;
+ created_at: string;
+ updated_at: string;
+ version: number;
+ patient_name?: string;
+}
+
+export interface CreateShiftReq {
+ shift_date: string;
+ period: string;
+ nurse_id?: string;
+ notes?: string;
+}
+
+export interface UpdateShiftReq {
+ shift_date?: string;
+ period?: string;
+ nurse_id?: string;
+ status?: string;
+ notes?: string;
+}
+
+export interface ListShiftsParams {
+ page?: number;
+ page_size?: number;
+ shift_date?: string;
+ period?: string;
+ nurse_id?: string;
+ status?: string;
+}
+
+export interface CreatePatientAssignmentReq {
+ patient_id: string;
+ care_level?: string;
+ notes?: string;
+}
+
+export interface BatchAssignReq {
+ patient_ids: string[];
+ care_level?: string;
+}
+
+export interface UpdatePatientAssignmentReq {
+ care_level?: string;
+ notes?: string;
+}
+
+export interface CreateHandoffReq {
+ from_shift_id: string;
+ to_shift_id: string;
+ patient_id: string;
+ notes?: string;
+ pending_items?: Record;
+}
+
+export interface ListHandoffParams {
+ page?: number;
+ page_size?: number;
+ from_shift_id?: string;
+ to_shift_id?: string;
+}
+
+// --- Constants ---
+
+export const PERIOD_OPTIONS = [
+ { label: '上午班', value: 'morning' },
+ { label: '下午班', value: 'afternoon' },
+ { label: '晚班', value: 'evening' },
+ { label: '夜班', value: 'night' },
+];
+
+export const SHIFT_STATUS_OPTIONS = [
+ { label: '待开始', value: 'scheduled' },
+ { label: '进行中', value: 'in_progress' },
+ { label: '已完成', value: 'completed' },
+ { label: '已取消', value: 'cancelled' },
+];
+
+export const CARE_LEVEL_OPTIONS = [
+ { label: '稳定', value: 'stable' },
+ { label: '需关注', value: 'attention' },
+ { label: '危重', value: 'critical' },
+];
+
+export const PERIOD_LABEL: Record = Object.fromEntries(
+ PERIOD_OPTIONS.map((o) => [o.value, o.label]),
+);
+
+export const SHIFT_STATUS_LABEL: Record = Object.fromEntries(
+ SHIFT_STATUS_OPTIONS.map((o) => [o.value, o.label]),
+);
+
+export const SHIFT_STATUS_COLOR: Record = {
+ scheduled: 'default',
+ in_progress: 'processing',
+ completed: 'success',
+ cancelled: 'error',
+};
+
+export const CARE_LEVEL_LABEL: Record = Object.fromEntries(
+ CARE_LEVEL_OPTIONS.map((o) => [o.value, o.label]),
+);
+
+export const CARE_LEVEL_COLOR: Record = {
+ stable: 'green',
+ attention: 'orange',
+ critical: 'red',
+};
+
+// --- API ---
+
+export const shiftApi = {
+ // --- Shifts ---
+
+ list: async (params: ListShiftsParams) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/health/shifts', { params });
+ return data.data;
+ },
+
+ get: async (shiftId: string) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: Shift;
+ }>(`/health/shifts/${shiftId}`);
+ return data.data;
+ },
+
+ create: async (req: CreateShiftReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: Shift;
+ }>('/health/shifts', req);
+ return data.data;
+ },
+
+ update: async (shiftId: string, req: UpdateShiftReq & { version: number }) => {
+ const { data } = await client.put<{
+ success: boolean;
+ data: Shift;
+ }>(`/health/shifts/${shiftId}`, req);
+ return data.data;
+ },
+
+ delete: async (shiftId: string, version: number) => {
+ await client.delete(`/health/shifts/${shiftId}`, { data: { version } });
+ },
+
+ // --- Assignments ---
+
+ listAssignments: async (shiftId: string, params?: { page?: number; page_size?: number }) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>(`/health/shifts/${shiftId}/assignments`, { params });
+ return data.data;
+ },
+
+ createAssignment: async (shiftId: string, req: CreatePatientAssignmentReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: PatientAssignment;
+ }>(`/health/shifts/${shiftId}/assignments`, req);
+ return data.data;
+ },
+
+ batchAssign: async (shiftId: string, req: BatchAssignReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: PatientAssignment[];
+ }>(`/health/shifts/${shiftId}/assignments/batch`, req);
+ return data.data;
+ },
+
+ updateAssignment: async (shiftId: string, assignmentId: string, req: UpdatePatientAssignmentReq & { version: number }) => {
+ const { data } = await client.put<{
+ success: boolean;
+ data: PatientAssignment;
+ }>(`/health/shifts/${shiftId}/assignments/${assignmentId}`, req);
+ return data.data;
+ },
+
+ deleteAssignment: async (shiftId: string, assignmentId: string, version: number) => {
+ await client.delete(`/health/shifts/${shiftId}/assignments/${assignmentId}`, { data: { version } });
+ },
+
+ // --- Handoff Logs ---
+
+ listHandoffs: async (params?: ListHandoffParams) => {
+ const { data } = await client.get<{
+ success: boolean;
+ data: PaginatedResponse;
+ }>('/health/handoff-logs', { params });
+ return data.data;
+ },
+
+ createHandoff: async (req: CreateHandoffReq) => {
+ const { data } = await client.post<{
+ success: boolean;
+ data: HandoffLog;
+ }>('/health/handoff-logs', req);
+ return data.data;
+ },
+};
diff --git a/apps/web/src/layouts/MainLayout.tsx b/apps/web/src/layouts/MainLayout.tsx
index cca3029..e51ef04 100644
--- a/apps/web/src/layouts/MainLayout.tsx
+++ b/apps/web/src/layouts/MainLayout.tsx
@@ -113,6 +113,8 @@ const routeTitleFallback: Record = {
'/health/follow-up-templates': '随访模板管理',
'/health/care-plans': '护理计划',
'/health/care-plans/:id': '护理计划详情',
+ '/health/shifts': '班次管理',
+ '/health/shifts/:id': '班次详情',
};
function getTitleFromMenus(path: string, menus: MenuInfo[]): string | undefined {
diff --git a/apps/web/src/pages/health/ShiftDetail.tsx b/apps/web/src/pages/health/ShiftDetail.tsx
new file mode 100644
index 0000000..d0dda5e
--- /dev/null
+++ b/apps/web/src/pages/health/ShiftDetail.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+
+ // Assignments
+ const [assignments, setAssignments] = useState([]);
+ const [assignLoading, setAssignLoading] = useState(false);
+ const [assignModalOpen, setAssignModalOpen] = useState(false);
+ const [editAssignment, setEditAssignment] = useState(null);
+ const [assignForm] = Form.useForm();
+
+ // Handoff logs
+ const [handoffs, setHandoffs] = useState([]);
+ 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 = [
+ { title: '患者 ID', dataIndex: 'patient_id', width: 280, ellipsis: true },
+ {
+ title: '护理等级',
+ dataIndex: 'care_level',
+ width: 120,
+ render: (v: string) => {CARE_LEVEL_LABEL[v] ?? v},
+ },
+ { title: '备注', dataIndex: 'notes', width: 200, ellipsis: true, render: (v: string) => v ?? '-' },
+ {
+ title: '操作', width: 140, render: (_, record) => hasPermission ? (
+
+
+ handleRemoveAssignment(record)}>
+
+
+
+ ) : null,
+ },
+ ];
+
+ const handoffColumns: ColumnsType = [
+ { 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 ;
+ if (!shift) return ;
+ if (!hasPermission) return ;
+
+ return (
+ navigate('/health/shifts')}
+ >
+
+
+ {SHIFT_STATUS_LABEL[shift.status] ?? shift.status}
+
+ {shift.patient_count ?? assignments.length}
+ {shift.nurse_id ?? '-'}
+ {shift.notes && {shift.notes}}
+
+
+
+
+
+
+
+ rowKey="id"
+ columns={assignColumns}
+ dataSource={assignments}
+ loading={assignLoading}
+ pagination={false}
+ size="small"
+ />
+ >
+ ),
+ },
+ {
+ key: 'handoffs',
+ label: `交接记录 (${handoffs.length})`,
+ children: (
+ <>
+
+
+ rowKey="id"
+ columns={handoffColumns}
+ dataSource={handoffs}
+ loading={handoffLoading}
+ pagination={false}
+ size="small"
+ />
+ >
+ ),
+ },
+ ]}
+ />
+
+ {/* Assignment Modal */}
+ setAssignModalOpen(false)}
+ width={480}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Handoff Modal */}
+ setHandoffModalOpen(false)}
+ width={520}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/health/ShiftList.tsx b/apps/web/src/pages/health/ShiftList.tsx
new file mode 100644
index 0000000..d3e9766
--- /dev/null
+++ b/apps/web/src/pages/health/ShiftList.tsx
@@ -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([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [loading, setLoading] = useState(false);
+ const [filters, setFilters] = useState({});
+
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editRecord, setEditRecord] = useState(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 = useMemo(() => [
+ {
+ title: '日期',
+ dataIndex: 'shift_date',
+ width: 120,
+ render: (v: string) => dayjs(v).format('YYYY-MM-DD'),
+ },
+ {
+ title: '班次',
+ dataIndex: 'period',
+ width: 100,
+ render: (v: string) => {PERIOD_LABEL[v] ?? v},
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (v: string) => (
+ {SHIFT_STATUS_LABEL[v] ?? v}
+ ),
+ },
+ {
+ title: '患者数',
+ dataIndex: 'patient_count',
+ width: 80,
+ render: (v: number) => v ?? 0,
+ },
+ {
+ title: '危重',
+ dataIndex: 'critical_count',
+ width: 70,
+ render: (v: number) => v ? {v} : 0,
+ },
+ {
+ title: '需关注',
+ dataIndex: 'attention_count',
+ width: 70,
+ render: (v: number) => v ? {v} : 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) => (
+
+
+
+ handleDelete(record)}>
+
+
+
+ ),
+ },
+ ], [navigate, page, filters]);
+
+ if (!hasPermission) {
+ return ;
+ }
+
+ return (
+ 新建班次}
+ >
+
+ handleFilterChange('shift_date', ds as string || undefined)}
+ style={{ width: 150 }}
+ />
+
+
+