# 医生和技师 - 布局与组件结构描述 ## 1️⃣ 页面概述 医生和技师页面用于管理转诊和执行医生及技师的名单信息,支持按类别(姓名、医生、技师)查看和管理人员信息,包括查看、创建、编辑、删除等操作。该页面主要用于DICOM报告和检查流程中的人员信息管理。 ## 2️⃣ 布局结构(使用约定术语) ### Page Layout - **Layout Type**: Vertical Stack Layout(垂直堆叠布局) - **Direction**: Vertical(垂直方向) - **Spacing**: 无间距(紧密堆叠) - **Padding**: 0(全屏布局) ### Component Hierarchy(层级结构) ``` Page (页面) └── Main Container (主容器) ├── Header Region (头部区域) │ ├── Title Component (标题组件) │ │ └── Typography.Title: "转诊/执行医生和技师名单" │ │ │ └── Tabs Component (选项卡组件) │ ├── Tab Item: 姓名 │ ├── Tab Item: 医生 │ └── Tab Item: 技师 │ ├── Content Region (内容区域) │ └── Table Section (表格分区) │ ├── Table Component (表格组件) │ │ ├── Column: 选择框 (Checkbox) │ │ ├── Column: 姓名/医生名称/技师名称 │ │ ├── Column: 工号/医生工号/技师工号 │ │ ├── Column: 科室 │ │ ├── Column: 职称 │ │ ├── Column: 联系电话 │ │ └── Column: 备注 │ │ │ ├── Empty State Component (空状态组件) │ │ ├── Icon: Empty illustration │ │ └── Text: "暂无数据" │ │ │ └── Pagination Component (分页组件) │ ├── Total Count Display (总数显示) │ ├── Page Size Selector (每页条数选择器) │ ├── Page Navigation (页码导航) │ └── Page Jump Input (页码跳转输入) │ └── Footer Region (底部区域) └── Action Toolbar (操作工具栏) ├── Button Group Left (左侧按钮组) │ ├── Button: 删除 │ └── Button: 编辑 │ └── Button Group Right (右侧按钮组) └── Button: 新建 (Primary) ``` ### Layout Properties(布局属性) #### Main Container - **Type**: Flexbox Layout - **Direction**: Column - **Height**: 100% - **Overflow**: Hidden - **Background**: Token.colorBgContainer - **Border-radius**: 8px #### Header Region - **Type**: Vertical Stack - **Padding**: 24px 24px 16px 24px - **Gap**: 16px - **Border-bottom**: 1px solid Token.colorBorderSecondary - **Background**: Token.colorBgContainer #### Title Component - **Font-size**: 20px (Typography.Title level 4) - **Font-weight**: 600 - **Color**: Token.colorTextHeading - **Margin-bottom**: 0 #### Tabs Component - **Type**: Line Tabs - **Size**: Default - **Tab-bar-gap**: 32px - **Active-color**: Token.colorPrimary #### Content Region - **Type**: Flex Layout - **Flex**: 1 (占据剩余空间) - **Overflow**: Auto - **Padding**: 24px 24px 0 24px #### Table Section - **Type**: Flexbox Layout - **Direction**: Column - **Height**: 100% - **Gap**: 16px #### Table Component - **Border**: 1px solid Token.colorBorder - **Border-radius**: 8px - **Row-height**: 54px - **Header-background**: Token.colorBgContainer - **Zebra-stripe**: Optional (可选斑马纹) #### Empty State - **Alignment**: Center - **Padding**: 80px 0 - **Icon-size**: 64px - **Text-color**: Token.colorTextSecondary #### Footer Region - **Height**: 72px - **Background**: Token.colorBgContainer - **Border-top**: 1px solid Token.colorBorderSecondary - **Padding**: 16px 24px - **Display**: Flex - **Justify**: Space-between - **Align**: Center #### Action Toolbar - **Type**: Horizontal Flex Layout - **Justify**: Space-between - **Width**: 100% #### Button Group - **Gap**: 12px - **Display**: Flex - **Align**: Center ## 3️⃣ Ant Design 组件选择 ### 使用的组件及理由 | 组件 | 用途 | 选择理由 | |------|------|---------| | **Typography.Title** | 页面标题 | - 提供标准的标题样式
- 自动适配主题
- 语义化标记
- 层级清晰(level 4) | | **Tabs** | 类别切换导航 | - 清晰的分类导航
- 自动处理激活状态
- 支持路由集成
- 适合管理同类数据的不同视图
- 视觉上区分不同类别的人员 | | **Table** | 数据展示 | - 企业级表格组件
- 内置排序、筛选功能
- 支持行选择
- 自动处理分页数据
- 列宽自适应
- 固定列支持 | | **Checkbox** | 行选择 | - 支持单选和多选
- 清晰的选中状态
- 便于批量操作
- 全选/取消全选支持 | | **Empty** | 空状态展示 | - 标准的空状态组件
- 提供友好的视觉提示
- 可自定义描述文字
- 统一的用户体验 | | **Pagination** | 分页控制 | - 完整的分页功能
- 支持页码跳转
- 每页条数选择
- 总数显示
- 国际化支持 | | **Button** | 操作按钮 | - 提供多种按钮样式
- 支持加载状态
- 图标支持
- 自动防重复点击
- 主题一致性 | | **Space** | 按钮组间距 | - 统一管理按钮间距
- 响应式布局
- 自动对齐 | | **Modal** | 弹窗对话框 | - 用于编辑/新建人员信息
- 模态交互
- 表单提交
- 确认删除操作 | | **Form** | 表单容器 | - 完整的表单验证
- 自动处理表单布局
- 内置错误显示
- 支持受控/非受控模式 | | **Input** | 文本输入 | - 标准文本输入组件
- 支持前缀/后缀图标
- 提供清空功能
- 自动trim空格 | | **Popconfirm** | 操作确认 | - 删除确认提示
- 轻量级确认
- 避免误操作 | ### Tabs 组件选择理由 选择 **Tabs** 作为分类导航的原因: - ✅ 清晰区分三种人员类别(姓名列表、医生列表、技师列表) - ✅ 符合用户心智模型,直观易懂 - ✅ 节省页面空间,避免多个独立列表 - ✅ 支持独立的数据加载和状态管理 - ✅ 便于后续扩展更多人员类别 - ✅ 与系统整体设计风格保持一致 ### 选择"姓名/医生/技师"分类的理由 - **姓名**:通用的人员列表,包含所有类型的人员 - **医生**:专门的医生列表,用于转诊医生和执行医生的管理 - **技师**:专门的技师列表,用于影像技师的管理 - 分类管理便于不同场景下的快速查找和选择 ### Table 布局选择理由 选择 **标准Table布局** 的原因: - ✅ 数据结构化展示,信息一目了然 - ✅ 支持排序,方便按姓名、工号等排序 - ✅ 支持行选择,便于批量操作 - ✅ 列宽可调整,适应不同内容长度 - ✅ 适合桌面端展示(医疗系统主要在桌面端使用) ## 4️⃣ 功能清单 ### 数据展示功能 - [x] **人员列表展示** - [ ] 显示姓名/医生名称/技师名称 - [ ] 显示工号 - [ ] 显示科室 - [ ] 显示职称 - [ ] 显示联系电话 - [ ] 显示备注信息 - [ ] 支持列排序 - [ ] 支持列筛选 - [x] **分类视图** - [ ] 姓名列表(所有人员) - [ ] 医生列表(仅医生) - [ ] 技师列表(仅技师) - [ ] 每个分类独立的数据和状态 - [x] **空状态展示** - [ ] 无数据时显示Empty组件 - [ ] 友好的提示文字 - [ ] 可选的操作引导(如"点击新建添加人员") - [x] **分页功能** - [ ] 显示总条数 - [ ] 每页条数选择(20/50/100) - [ ] 页码导航 - [ ] 快速跳转到指定页 - [x] **数据加载** - [ ] 初始加载人员列表 - [ ] 加载状态指示 - [ ] 加载失败处理 - [ ] Tab切换时加载对应数据 ### 人员操作功能 - [x] **新建人员** - [ ] 打开新建表单Modal - [ ] 输入人员基本信息 - [ ] 选择人员类别(医生/技师) - [ ] 输入工号 - [ ] 输入科室 - [ ] 输入职称 - [ ] 输入联系电话 - [ ] 输入备注 - [ ] 表单验证 - [ ] 提交创建请求 - [x] **编辑人员** - [ ] 选中人员(单选) - [ ] 打开编辑表单Modal - [ ] 修改人员信息 - [ ] 保存修改 - [x] **删除人员** - [ ] 单选删除 - [ ] 批量删除 - [ ] 删除确认提示 - [ ] 删除操作 - [ ] 删除结果反馈 - [x] **刷新列表** - [ ] 重新加载当前Tab的数据 - [ ] 保持分页和筛选状态 - [ ] 刷新状态提示 ### 选择与批量操作 - [x] **行选择** - [ ] 单选人员 - [ ] 多选人员(复选框) - [ ] 全选/取消全选 - [ ] 显示已选数量 - [x] **批量操作** - [ ] 批量删除 - [ ] 批量导出(可选) - [ ] 批量修改科室(可选) ### 搜索与筛选 - [x] **搜索功能** - [ ] 按姓名搜索 - [ ] 按工号搜索 - [ ] 按科室筛选 - [ ] 按职称筛选 - [ ] 实时搜索 ### Tab 切换功能 - [x] **分类切换** - [ ] 切换到姓名列表 - [ ] 切换到医生列表 - [ ] 切换到技师列表 - [ ] 保持各Tab的独立状态 ## 5️⃣ 功能需求与思考 ### 功能1:人员列表展示与Tab切换 **需求描述**: - 页面分为三个Tab:姓名(所有人员)、医生、技师 - 每个Tab显示对应类别的人员列表 - 支持分页展示,每页默认20条 **交互流程**: 1. 页面加载时默认显示"姓名"Tab 2. 自动加载第一页数据 3. 显示加载状态 4. 数据加载成功后渲染表格 5. 用户点击其他Tab时切换视图 6. 加载对应Tab的数据(如未加载过) **数据结构**: ```typescript // 人员类型枚举 enum StaffType { ALL = 'all', // 姓名列表(所有人员) DOCTOR = 'doctor', // 医生 TECHNICIAN = 'technician' // 技师 } // 人员信息接口 interface StaffMember { id: string; name: string; // 姓名 staffNumber: string; // 工号 department: string; // 科室 title: string; // 职称 phone: string; // 联系电话 type: StaffType; // 人员类型 remark?: string; // 备注 createdAt: string; // 创建时间 updatedAt: string; // 更新时间 } // 列表响应接口 interface StaffListResponse { data: StaffMember[]; total: number; page: number; pageSize: number; } ``` **Tab设计说明**: - **姓名Tab**:显示所有人员,不区分类型 - **医生Tab**:只显示type为doctor的人员 - **技师Tab**:只显示type为technician的人员 **边界情况**: - Tab切换时保持各自的分页状态 - 无数据时显示Empty组件 - 加载失败提供重试机制 - 网络慢时显示骨架屏 ### 功能2:新建人员 **需求描述**: - 通过Modal表单创建新的医生或技师记录 - 支持输入完整的人员信息 **交互流程**: 1. 点击"新建"按钮 2. 打开新建人员Modal 3. 填写表单: - 姓名(必填) - 人员类型(必填,单选:医生/技师) - 工号(必填,唯一性校验) - 科室(必填) - 职称(可选) - 联系电话(可选,格式校验) - 备注(可选) 4. 点击"确定"提交 5. 显示创建进度 6. 成功:关闭Modal,刷新当前Tab列表,显示成功消息 7. 失败:显示错误信息,保持Modal打开 **表单字段设计**: ```typescript interface CreateStaffForm { name: string; // 2-20字符 type: StaffType; // 医生或技师 staffNumber: string; // 工号,3-20字符,字母数字 department: string; // 科室,2-50字符 title?: string; // 职称,可选 phone?: string; // 11位手机号 remark?: string; // 备注,最多200字符 } ``` **验证规则**: - 姓名:必填,2-20个字符,中文或英文 - 人员类型:必选,医生或技师 - 工号:必填,3-20个字符,唯一性校验 - 科室:必填,2-50个字符 - 职称:可选,最多20个字符 - 联系电话:可选,11位手机号格式 - 备注:可选,最多200个字符 **业务规则**: - 工号在系统中必须唯一 - 同一姓名可以有多条记录(但工号不同) - 新建后根据type字段自动出现在对应的Tab中 - 所有人员都会出现在"姓名"Tab中 **边界情况**: - 工号已存在 - 表单验证失败 - 网络超时 - 服务器错误 - Modal关闭时清空表单 ### 功能3:编辑人员 **需求描述**: - 修改现有人员的信息 - 工号和人员类型不可修改 **交互流程**: 1. 选中一个人员(单选) 2. 点击"编辑"按钮 3. 打开编辑Modal 4. 表单预填充当前人员信息 5. 修改可编辑字段 6. 提交修改 7. 成功:关闭Modal,刷新列表 8. 失败:显示错误信息 **可编辑字段**: - 姓名 - 科室 - 职称 - 联系电话 - 备注 **不可编辑字段**: - 工号(唯一标识,不可修改) - 人员类型(创建后不可修改) - 创建时间 **按钮状态**: - 未选中:编辑按钮禁用 - 选中1个:编辑按钮启用 - 选中多个:编辑按钮禁用(不支持批量编辑) **边界情况**: - 未选中人员点击编辑 - 选中多个人员(禁用编辑按钮) - 编辑过程中人员被删除 - 并发编辑冲突 ### 功能4:删除人员 **需求描述**: - 删除选中的人员记录 - 支持单个删除和批量删除 - 提供删除确认,防止误操作 **交互流程**: 1. 选中要删除的人员(单选或多选) 2. 点击"删除"按钮 3. 弹出Popconfirm确认对话框 4. 用户确认删除 5. 发送删除请求 6. 成功:从列表移除,更新分页,显示成功消息 7. 失败:显示错误信息 **确认对话框内容**: - 单个删除:`确定要删除 "${name}" 吗?` - 批量删除:`确定要删除选中的 ${count} 个人员吗?` - 警告提示:`删除后将无法恢复。` **删除限制**: - 需要相应的权限 - 如果人员被其他记录引用(如检查记录中的执行医生),需要特殊处理: - 软删除(标记为已删除,保留数据) - 或者提示无法删除,需先处理关联数据 **级联影响**: - 从所有Tab中移除 - 更新总数统计 - 如果删除当前页最后一条记录,自动跳转到上一页 **边界情况**: - 未选中人员点击删除(禁用按钮) - 删除过程中网络中断 - 删除后分页处理(如删除最后一页的唯一记录) - 批量删除部分成功部分失败 - 人员被引用时的处理 ### 功能5:表格列设计 **需求描述**: - 设计合理的表格列,清晰展示人员信息 - 支持排序和筛选 **列配置**: | 列名 | 宽度 | 对齐 | 排序 | 筛选 | 说明 | |------|------|------|------|------|------| | 选择框 | 50px | Center | - | - | Checkbox,用于行选择 | | 姓名 | 120px | Left | ✅ | ✅ | 主要标识,支持搜索 | | 工号 | 120px | Left | ✅ | ✅ | 唯一标识,支持搜索 | | 科室 | 150px | Left | ✅ | ✅ | 支持筛选下拉 | | 职称 | 120px | Left | ✅ | ✅ | 支持筛选下拉 | | 联系电话 | 130px | Left | - | ✅ | 可为空 | | 备注 | Auto | Left | - | - | 可为空,超长省略 | **排序功能**: - 姓名:按拼音排序 - 工号:按字符串排序 - 科室:按拼音排序 - 职称:按拼音排序 **筛选功能**: - 姓名:输入框搜索 - 工号:输入框搜索 - 科室:下拉多选筛选 - 职称:下拉多选筛选 - 联系电话:输入框搜索 **响应式处理**: - 小屏幕时隐藏备注列 - 超小屏幕时隐藏联系电话和备注列 - 移动端考虑使用卡片式布局 ### 功能6:空状态处理 **需求描述**: - 当列表无数据时,显示友好的空状态 - 提供操作引导 **空状态设计**: - 使用Ant Design的Empty组件 - 显示"暂无数据"文字 - 可选:显示"点击新建按钮添加人员"的引导文字 - 图标:使用Empty的默认图标或自定义图标 **触发场景**: - 首次访问且无数据 - 搜索/筛选后无结果 - Tab切换到空的分类 **交互**: - 空状态下新建按钮仍然可用 - 其他操作按钮保持禁用状态 ### 功能7:分页处理 **需求描述**: - 支持大量数据的分页展示 - 提供灵活的分页控制 **分页配置**: - 默认每页:20条 - 可选每页:20/50/100条 - 显示总数:`共 ${total} 条` - 页码导航:最多显示7个页码 - 快速跳转:输入页码直接跳转 **分页状态管理**: ```typescript interface PaginationState { current: number; // 当前页码 pageSize: number; // 每页条数 total: number; // 总条数 } ``` **分页行为**: - Tab切换时各自维护独立的分页状态 - 删除数据后智能处理: - 如果当前页还有数据,保持当前页 - 如果当前页空了,跳转到上一页 - 如果只有一页且空了,显示空状态 - 新建数据后跳转到第一页 - 编辑数据后保持当前页 **性能优化**: - 只加载当前页数据 - 避免重复请求 - 缓存已加载的页面数据(可选) ### 功能8:搜索与筛选 **需求描述**: - 支持按多个字段搜索和筛选 - 实时反馈搜索结果 **搜索方式**: 1. **快速搜索**: - 顶部搜索框 - 支持按姓名或工号搜索 - 实时搜索或延迟搜索(300ms防抖) 2. **高级筛选**: - 表格列头的筛选下拉 - 科室筛选:多选下拉 - 职称筛选:多选下拉 **筛选组合**: - 多个筛选条件使用AND逻辑 - 搜索和筛选可以同时使用 - 筛选后自动重置到第一页 **清空筛选**: - 提供"清空"按钮 - 清空后恢复到初始状态 **筛选状态指示**: - 激活的筛选条件高亮显示 - 显示当前筛选条件数量 ### 功能9:数据导入导出(可选) **需求描述**: - 支持批量导入人员数据 - 支持导出人员列表 **导入功能**: - 上传Excel文件 - 模板下载 - 数据校验 - 错误提示 - 导入结果反馈 **导出功能**: - 导出当前Tab的数据 - 导出选中的数据 - 导出为Excel格式 - 包含所有列信息 ## 6️⃣ 后续实现建议 ### 状态管理 #### Redux Slice 设计 ```typescript // states/staffManagementSlice.ts interface StaffManagementState { // 各Tab的数据 tabs: { [key in StaffType]: { data: StaffMember[]; total: number; loading: boolean; error: string | null; pagination: { current: number; pageSize: number; }; filters: { keyword: string; department: string[]; title: string[]; }; sorter: { field: string | null; order: 'ascend' | 'descend' | null; }; }; }; // 当前激活的Tab activeTab: StaffType; // 选择状态 selection: { selectedRowKeys: string[]; selectedRows: StaffMember[]; }; // 模态框状态 modals: { createStaff: boolean; editStaff: boolean; }; // 当前编辑的人员 currentStaff: StaffMember | null; // 操作状态 operations: { creating: boolean; updating: boolean; deleting: boolean; }; } // 初始状态 const initialState: StaffManagementState = { tabs: { [StaffType.ALL]: { data: [], total: 0, loading: false, error: null, pagination: { current: 1, pageSize: 20 }, filters: { keyword: '', department: [], title: [] }, sorter: { field: null, order: null }, }, [StaffType.DOCTOR]: { data: [], total: 0, loading: false, error: null, pagination: { current: 1, pageSize: 20 }, filters: { keyword: '', department: [], title: [] }, sorter: { field: null, order: null }, }, [StaffType.TECHNICIAN]: { data: [], total: 0, loading: false, error: null, pagination: { current: 1, pageSize: 20 }, filters: { keyword: '', department: [], title: [] }, sorter: { field: null, order: null }, }, }, activeTab: StaffType.ALL, selection: { selectedRowKeys: [], selectedRows: [], }, modals: { createStaff: false, editStaff: false, }, currentStaff: null, operations: { creating: false, updating: false, deleting: false, }, }; // 异步 Thunks export const fetchStaffList = createAsyncThunk( 'staffManagement/fetchStaffList', async ({ type, params }: { type: StaffType; params: FetchStaffParams }) => { const response = await getStaffList(type, params); return { type, data: response }; } ); export const createStaff = createAsyncThunk( 'staffManagement/createStaff', async (data: CreateStaffForm) => { const response = await createStaffAPI(data); return response; } ); export const updateStaff = createAsyncThunk( 'staffManagement/updateStaff', async ({ id, data }: { id: string; data: UpdateStaffForm }) => { const response = await updateStaffAPI(id, data); return response; } ); export const deleteStaff = createAsyncThunk( 'staffManagement/deleteStaff', async (ids: string[]) => { await deleteStaffAPI(ids); return ids; } ); // Slice 定义 const staffManagementSlice = createSlice({ name: 'staffManagement', initialState, reducers: { // 切换 Tab setActiveTab: (state, action: PayloadAction) => { state.activeTab = action.payload; // 清空选择状态 state.selection = { selectedRowKeys: [], selectedRows: [] }; }, // 更新分页 updatePagination: (state, action: PayloadAction<{ type: StaffType; pagination: Partial }>) => { const { type, pagination } = action.payload; state.tabs[type].pagination = { ...state.tabs[type].pagination, ...pagination, }; }, // 更新筛选 updateFilters: (state, action: PayloadAction<{ type: StaffType; filters: Partial }>) => { const { type, filters } = action.payload; state.tabs[type].filters = { ...state.tabs[type].filters, ...filters, }; }, // 更新排序 updateSorter: (state, action: PayloadAction<{ type: StaffType; sorter: typeof initialState.tabs[StaffType]['sorter'] }>) => { const { type, sorter } = action.payload; state.tabs[type].sorter = sorter; }, // 更新选择 setSelection: (state, action: PayloadAction<{ selectedRowKeys: string[]; selectedRows: StaffMember[] }>) => { state.selection = action.payload; }, // 打开/关闭模态框 openModal: (state, action: PayloadAction) => { state.modals[action.payload] = true; }, closeModal: (state, action: PayloadAction) => { state.modals[action.payload] = false; }, // 设置当前编辑的人员 setCurrentStaff: (state, action: PayloadAction) => { state.currentStaff = action.payload; }, }, extraReducers: (builder) => { // 加载列表 builder.addCase(fetchStaffList.pending, (state, action) => { const { type } = action.meta.arg; state.tabs[type].loading = true; state.tabs[type].error = null; }); builder.addCase(fetchStaffList.fulfilled, (state, action) => { const { type, data } = action.payload; state.tabs[type].loading = false; state.tabs[type].data = data.data; state.tabs[type].total = data.total; }); builder.addCase(fetchStaffList.rejected, (state, action) => { const { type } = action.meta.arg; state.tabs[type].loading = false; state.tabs[type].error = action.error.message || '加载失败'; }); // 创建人员 builder.addCase(createStaff.pending, (state) => { state.operations.creating = true; }); builder.addCase(createStaff.fulfilled, (state) => { state.operations.creating = false; state.modals.createStaff = false; }); builder.addCase(createStaff.rejected, (state) => { state.operations.creating = false; }); // 更新人员 builder.addCase(updateStaff.pending, (state) => { state.operations.updating = true; }); builder.addCase(updateStaff.fulfilled, (state) => { state.operations.updating = false; state.modals.editStaff = false; state.currentStaff = null; }); builder.addCase(updateStaff.rejected, (state) => { state.operations.updating = false; }); // 删除人员 builder.addCase(deleteStaff.pending, (state) => { state.operations.deleting = true; }); builder.addCase(deleteStaff.fulfilled, (state) => { state.operations.deleting = false; state.selection = { selectedRowKeys: [], selectedRows: [] }; }); builder.addCase(deleteStaff.rejected, (state) => { state.operations.deleting = false; }); }, }); export const { setActiveTab, updatePagination, updateFilters, updateSorter, setSelection, openModal, closeModal, setCurrentStaff, } = staffManagementSlice.actions; export default staffManagementSlice.reducer; ``` ### API 接口设计 ```typescript // API/staff.ts // 获取人员列表参数 export interface FetchStaffParams { page: number; pageSize: number; keyword?: string; department?: string[]; title?: string[]; sortField?: string; sortOrder?: 'asc' | 'desc'; } // 获取人员列表 export const getStaffList = ( type: StaffType, params: FetchStaffParams ): Promise => { return request.get('/api/system/staff', { params: { ...params, type: type === StaffType.ALL ? undefined : type, }, }); }; // 创建人员 export const createStaffAPI = (data: CreateStaffForm): Promise => { return request.post('/api/system/staff', data); }; // 更新人员 export interface UpdateStaffForm { name?: string; department?: string; title?: string; phone?: string; remark?: string; } export const updateStaffAPI = (id: string, data: UpdateStaffForm): Promise => { return request.put(`/api/system/staff/${id}`, data); }; // 删除人员 export const deleteStaffAPI = (ids: string[]): Promise => { return request.delete('/api/system/staff', { data: { ids } }); }; // 检查工号是否存在 export const checkStaffNumberExists = (staffNumber: string): Promise => { return request.get('/api/system/staff/check-staff-number', { params: { staffNumber }, }); }; // 获取科室列表 export const getDepartmentList = (): Promise => { return request.get('/api/system/staff/departments'); }; // 获取职称列表 export const getTitleList = (): Promise => { return request.get('/api/system/staff/titles'); }; ``` ### 表单验证规则 ```typescript // validation/staffRules.ts import { z } from 'zod'; export const createStaffSchema = z.object({ name: z.string() .min(2, '姓名至少2个字符') .max(20, '姓名最多20个字符') .regex(/^[\u4e00-\u9fa5a-zA-Z\s]+$/, '姓名只能包含中文、英文和空格'), type: z.enum([StaffType.DOCTOR, StaffType.TECHNICIAN], { errorMap: () => ({ message: '请选择人员类型' }), }), staffNumber: z.string() .min(3, '工号至少3个字符') .max(20, '工号最多20个字符') .regex(/^[A-Z0-9]+$/, '工号只能包含大写字母和数字'), department: z.string() .min(2, '科室名称至少2个字符') .max(50, '科室名称最多50个字符'), title: z.string() .max(20, '职称最多20个字符') .optional(), phone: z.string() .regex(/^1[3-9]\d{9}$/, '请输入正确的手机号') .optional() .or(z.literal('')), remark: z.string() .max(200, '备注最多200个字符') .optional(), }); export const updateStaffSchema = createStaffSchema.omit({ type: true, staffNumber: true, }); // Ant Design Form 验证规则 export const staffFormRules = { name: [ { required: true, message: '请输入姓名' }, { min: 2, max: 20, message: '姓名长度为2-20个字符' }, { pattern: /^[\u4e00-\u9fa5a-zA-Z\s]+$/, message: '姓名只能包含中文、英文和空格' }, ], type: [ { required: true, message: '请选择人员类型' }, ], staffNumber: [ { required: true, message: '请输入工号' }, { min: 3, max: 20, message: '工号长度为3-20个字符' }, { pattern: /^[A-Z0-9]+$/, message: '工号只能包含大写字母和数字' }, // 异步验证唯一性 { validator: async (_, value: string) => { if (!value) return Promise.resolve(); const exists = await checkStaffNumberExists(value); if (exists) { return Promise.reject(new Error('该工号已存在')); } return Promise.resolve(); }, }, ], department: [ { required: true, message: '请输入科室' }, { min: 2, max: 50, message: '科室名称长度为2-50个字符' }, ], title: [ { max: 20, message: '职称最多20个字符' }, ], phone: [ { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号' }, ], remark: [ { max: 200, message: '备注最多200个字符' }, ], }; ``` ### 组件实现骨架 ```typescript // sections/SystemHome/DoctorsAndTechnicians.tsx import React, { useEffect } from 'react'; import { Typography, Tabs, Table, Button, Space, Empty, Modal, Form, Input, Select, message, Popconfirm } from 'antd'; import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { useAppDispatch, useAppSelector } from '@/states/store'; import { fetchStaffList, createStaff, updateStaff, deleteStaff, setActiveTab, updatePagination, setSelection, openModal, closeModal, setCurrentStaff, } from '@/states/staffManagementSlice'; import { StaffType } from '@/types/staff'; import { staffFormRules } from '@/validation/staffRules'; const DoctorsAndTechnicians: React.FC = () => { const dispatch = useAppDispatch(); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); const { tabs, activeTab, selection, modals, currentStaff, operations, } = useAppSelector(state => state.staffManagement); const currentTabData = tabs[activeTab]; // 初始加载 useEffect(() => { loadData(); }, [activeTab]); // 加载数据 const loadData = () => { dispatch(fetchStaffList({ type: activeTab, params: { page: currentTabData.pagination.current, pageSize: currentTabData.pagination.pageSize, ...currentTabData.filters, sortField: currentTabData.sorter.field || undefined, sortOrder: currentTabData.sorter.order === 'ascend' ? 'asc' : currentTabData.sorter.order === 'descend' ? 'desc' : undefined, }, })); }; // Tab 切换 const handleTabChange = (key: string) => { dispatch(setActiveTab(key as StaffType)); }; // 表格列定义 const columns = [ { title: '姓名', dataIndex: 'name', key: 'name', width: 120, sorter: true, }, { title: '工号', dataIndex: 'staffNumber', key: 'staffNumber', width: 120, sorter: true, }, { title: '科室', dataIndex: 'department', key: 'department', width: 150, sorter: true, }, { title: '职称', dataIndex: 'title', key: 'title', width: 120, sorter: true, }, { title: '联系电话', dataIndex: 'phone', key: 'phone', width: 130, }, { title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true, }, ]; // 表格配置 const tableConfig = { rowKey: 'id', columns, dataSource: currentTabData.data, loading: currentTabData.loading, rowSelection: { selectedRowKeys: selection.selectedRowKeys, onChange: (selectedRowKeys: React.Key[], selectedRows: StaffMember[]) => { dispatch(setSelection({ selectedRowKeys: selectedRowKeys as string[], selectedRows, })); }, }, pagination: { current: currentTabData.pagination.current, pageSize: currentTabData.pagination.pageSize, total: currentTabData.total, showSizeChanger: true, showQuickJumper: true, showTotal: (total: number) => `共 ${total} 条`, pageSizeOptions: ['20', '50', '100'], onChange: (page: number, pageSize: number) => { dispatch(updatePagination({ type: activeTab, pagination: { current: page, pageSize }, })); }, }, locale: { emptyText: , }, }; // 新建人员 const handleCreate = () => { createForm.resetFields(); dispatch(openModal('createStaff')); }; const handleCreateSubmit = async () => { try { const values = await createForm.validateFields(); await dispatch(createStaff(values)).unwrap(); message.success('创建成功'); loadData(); } catch (error) { message.error('创建失败'); } }; // 编辑人员 const handleEdit = () => { if (selection.selectedRows.length !== 1) return; const staff = selection.selectedRows[0]; dispatch(setCurrentStaff(staff)); editForm.setFieldsValue(staff); dispatch(openModal('editStaff')); }; const handleEditSubmit = async () => { try { const values = await editForm.validateFields(); await dispatch(updateStaff({ id: currentStaff!.id, data: values, })).unwrap(); message.success('更新成功'); loadData(); } catch (error) { message.error('更新失败'); } }; // 删除人员 const handleDelete = async () => { try { await dispatch(deleteStaff(selection.selectedRowKeys)).unwrap(); message.success('删除成功'); loadData(); } catch (error) { message.error('删除失败'); } }; return (
{/* 头部 */}
转诊/执行医生和技师名单
{/* 内容区 */}
{/* 底部操作栏 */}
{/* 新建Modal */} dispatch(closeModal('createStaff'))} confirmLoading={operations.creating} width={600} >
{/* 编辑Modal */} dispatch(closeModal('editStaff'))} confirmLoading={operations.updating} width={600} >
); }; export default DoctorsAndTechnicians; ``` ### 国际化支持 ```typescript // assets/i18n/zh-CN.json { "systemSettings": { "staff": { "title": "转诊/执行医生和技师名单", "tabs": { "all": "姓名", "doctor": "医生", "technician": "技师" }, "columns": { "name": "姓名", "staffNumber": "工号", "department": "科室", "title": "职称", "phone": "联系电话", "remark": "备注" }, "actions": { "create": "新建", "edit": "编辑", "delete": "删除" }, "form": { "name": "姓名", "type": "人员类型", "staffNumber": "工号", "department": "科室", "title": "职称", "phone": "联系电话", "remark": "备注" }, "messages": { "createSuccess": "创建成功", "createFailed": "创建失败", "updateSuccess": "更新成功", "updateFailed": "更新失败", "deleteSuccess": "删除成功", "deleteFailed": "删除失败", "deleteConfirm": "确定要删除选中的 {count} 个人员吗?", "deleteWarning": "删除后将无法恢复。" } } } } ``` ## 7️⃣ 测试要点 ### 单元测试 - Redux slice 逻辑测试 - 表单验证规则测试 - API 接口调用测试 - 工具函数测试 ### 集成测试 - 完整的增删改查流程 - Tab 切换与数据加载 - 分页和筛选功能 - 错误处理测试 ### E2E 测试 ```typescript // cypress/e2e/system/staff-management.cy.ts describe('医生和技师管理', () => { beforeEach(() => { cy.login(); cy.visit('/system/settings?section=doctors-technicians'); }); it('应该能够加载人员列表', () => { cy.get('[data-testid="staff-table"]').should('be.visible'); cy.get('[data-testid="staff-table"] tbody tr').should('have.length.gt', 0); }); it('应该能够切换Tab', () => { cy.contains('医生').click(); cy.url().should('include', 'tab=doctor'); cy.contains('技师').click(); cy.url().should('include', 'tab=technician'); }); it('应该能够新建人员', () => { cy.contains('button', '新建').click(); cy.get('[data-testid="create-staff-modal"]').should('be.visible'); cy.get('input[name="name"]').type('张三'); cy.get('select[name="type"]').select('医生'); cy.get('input[name="staffNumber"]').type('DOC001'); cy.get('input[name="department"]').type('放射科'); cy.contains('button', '确定').click(); cy.contains('创建成功').should('be.visible'); }); it('应该能够编辑人员', () => { // 选中第一行 cy.get('[data-testid="staff-table"] tbody tr').first().click(); cy.contains('button', '编辑').click(); cy.get('[data-testid="edit-staff-modal"]').should('be.visible'); cy.get('input[name="department"]').clear().type('影像科'); cy.contains('button', '确定').click(); cy.contains('更新成功').should('be.visible'); }); it('应该能够删除人员', () => { // 选中第一行 cy.get('[data-testid="staff-table"] tbody tr').first().click(); cy.contains('button', '删除').click(); cy.get('[data-testid="delete-confirm"]').should('be.visible'); cy.contains('button', '确定').click(); cy.contains('删除成功').should('be.visible'); }); }); ``` ## 8️⃣ 性能优化建议 1. **数据缓存** - 使用 React Query 或 SWR 缓存请求数据 - Tab 切换时复用已加载的数据 - 设置合理的缓存过期时间 2. **虚拟滚动** - 数据量大时使用虚拟滚动 - Ant Design Table 支持虚拟滚动配置 3. **防抖和节流** - 搜索输入使用防抖(300ms) - 滚动加载使用节流 4. **懒加载** - 组件级别的代码分割 - 图片懒加载(如果有) 5. **优化重渲染** - 使用 React.memo 优化组件 - 使用 useMemo 和 useCallback - 避免不必要的状态更新 ## 9️⃣ 安全性考虑 1. **输入验证** - 前端:表单验证 - 后端:双重验证 - XSS 防护:转义用户输入 2. **权限控制** - 基于角色的访问控制(RBAC) - 只有管理员可以管理人员信息 - 操作日志记录 3. **数据安全** - HTTPS 传输 - 敏感信息加密存储 - 定期备份 4. **审计日志** - 记录所有创建、编辑、删除操作 - 记录操作人、操作时间、操作内容 - 日志不可篡改 ## 🔟 附录 ### 交互流程图 ``` 用户打开页面 ↓ 加载姓名Tab数据 ↓ 显示列表 ↓ 用户操作(切换Tab/新建/编辑/删除) ↓ 执行对应操作 ↓ 操作成功 → 更新列表 → 显示成功提示 ↓ 操作失败 → 显示错误提示 → 允许重试 ``` ### 数据流图 ``` 组件 → Redux Action → Async Thunk → API → 后端 ↓ 组件 ← Redux State ← Redux Reducer ← Response ``` ### 关键决策记录 1. **为什么使用Tab而不是独立页面?** - 节省导航空间 - 相关功能集中管理 - 更好的用户体验 2. **为什么工号不可修改?** - 工号作为唯一标识 - 避免数据关联混乱 - 符合医疗系统规范 3. **为什么支持同名人员?** - 现实中可能存在同名情况 - 通过工号区分 - 保证数据完整性 4. **为什么采用软删除?** - 保留历史数据 - 支持数据恢复 - 满足审计要求 ## 1️⃣1️⃣ 总结 医生和技师管理页面是系统之家模块的重要组成部分,主要用于管理医疗系统中的医生和技师信息。通过合理的布局设计、清晰的交互流程和完善的功能实现,为用户提供高效便捷的人员管理体验。 ### 核心特性 1. **分类管理**:通过Tab实现医生和技师的分类查看 2. **完整CRUD**:支持人员信息的创建、读取、更新、删除 3. **批量操作**:支持批量删除等批量操作 4. **数据验证**:前后端双重验证确保数据质量 5. **权限控制**:基于角色的访问控制保障安全 ### 技术栈 - **前端框架**:React + TypeScript - **UI组件库**:Ant Design - **状态管理**:Redux Toolkit - **表单验证**:Zod + Ant Design Form - **HTTP客户端**:Axios ### 后续规划 1. **功能增强** - 添加导入导出功能 - 支持更多筛选维度 - 增加统计分析功能 2. **性能优化** - 实现虚拟滚动 - 优化缓存策略 - 减少重渲染 3. **用户体验** - 添加快捷键支持 - 优化移动端适配 - 增加操作引导 4. **系统集成** - 与HIS系统对接 - 与LDAP集成 - 支持单点登录