医生和技师-布局与组件结构描述.md 45 KB

医生和技师 - 布局与组件结构描述

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] 批量操作

    • 批量删除
    • 批量导出(可选)
    • 批量修改科室(可选)

搜索与筛选

  • 搜索功能
    • 按姓名搜索
    • 按工号搜索
    • 按科室筛选
    • 按职称筛选
    • 实时搜索

Tab 切换功能

  • 分类切换
    • 切换到姓名列表
    • 切换到医生列表
    • 切换到技师列表
    • 保持各Tab的独立状态

5️⃣ 功能需求与思考

功能1:人员列表展示与Tab切换

需求描述

  • 页面分为三个Tab:姓名(所有人员)、医生、技师
  • 每个Tab显示对应类别的人员列表
  • 支持分页展示,每页默认20条

交互流程

  1. 页面加载时默认显示"姓名"Tab
  2. 自动加载第一页数据
  3. 显示加载状态
  4. 数据加载成功后渲染表格
  5. 用户点击其他Tab时切换视图
  6. 加载对应Tab的数据(如未加载过)

数据结构

// 人员类型枚举
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打开

表单字段设计

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个页码
  • 快速跳转:输入页码直接跳转

分页状态管理

interface PaginationState {
  current: number;    // 当前页码
  pageSize: number;   // 每页条数
  total: number;      // 总条数
}

分页行为

  • Tab切换时各自维护独立的分页状态
  • 删除数据后智能处理:
    • 如果当前页还有数据,保持当前页
    • 如果当前页空了,跳转到上一页
    • 如果只有一页且空了,显示空状态
  • 新建数据后跳转到第一页
  • 编辑数据后保持当前页

性能优化

  • 只加载当前页数据
  • 避免重复请求
  • 缓存已加载的页面数据(可选)

功能8:搜索与筛选

需求描述

  • 支持按多个字段搜索和筛选
  • 实时反馈搜索结果

搜索方式

  1. 快速搜索

    • 顶部搜索框
    • 支持按姓名或工号搜索
    • 实时搜索或延迟搜索(300ms防抖)
  2. 高级筛选

    • 表格列头的筛选下拉
    • 科室筛选:多选下拉
    • 职称筛选:多选下拉

筛选组合

  • 多个筛选条件使用AND逻辑
  • 搜索和筛选可以同时使用
  • 筛选后自动重置到第一页

清空筛选

  • 提供"清空"按钮
  • 清空后恢复到初始状态

筛选状态指示

  • 激活的筛选条件高亮显示
  • 显示当前筛选条件数量

功能9:数据导入导出(可选)

需求描述

  • 支持批量导入人员数据
  • 支持导出人员列表

导入功能

  • 上传Excel文件
  • 模板下载
  • 数据校验
  • 错误提示
  • 导入结果反馈

导出功能

  • 导出当前Tab的数据
  • 导出选中的数据
  • 导出为Excel格式
  • 包含所有列信息

6️⃣ 后续实现建议

状态管理

Redux Slice 设计

// 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<StaffType>) => {
      state.activeTab = action.payload;
      // 清空选择状态
      state.selection = { selectedRowKeys: [], selectedRows: [] };
    },
    
    // 更新分页
    updatePagination: (state, action: PayloadAction<{ type: StaffType; pagination: Partial<PaginationState> }>) => {
      const { type, pagination } = action.payload;
      state.tabs[type].pagination = {
        ...state.tabs[type].pagination,
        ...pagination,
      };
    },
    
    // 更新筛选
    updateFilters: (state, action: PayloadAction<{ type: StaffType; filters: Partial<typeof initialState.tabs[StaffType]['filters']> }>) => {
      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<keyof typeof initialState.modals>) => {
      state.modals[action.payload] = true;
    },
    closeModal: (state, action: PayloadAction<keyof typeof initialState.modals>) => {
      state.modals[action.payload] = false;
    },
    
    // 设置当前编辑的人员
    setCurrentStaff: (state, action: PayloadAction<StaffMember | null>) => {
      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 接口设计

// 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<StaffListResponse> => {
  return request.get('/api/system/staff', {
    params: {
      ...params,
      type: type === StaffType.ALL ? undefined : type,
    },
  });
};

// 创建人员
export const createStaffAPI = (data: CreateStaffForm): Promise<StaffMember> => {
  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<StaffMember> => {
  return request.put(`/api/system/staff/${id}`, data);
};

// 删除人员
export const deleteStaffAPI = (ids: string[]): Promise<void> => {
  return request.delete('/api/system/staff', { data: { ids } });
};

// 检查工号是否存在
export const checkStaffNumberExists = (staffNumber: string): Promise<boolean> => {
  return request.get('/api/system/staff/check-staff-number', {
    params: { staffNumber },
  });
};

// 获取科室列表
export const getDepartmentList = (): Promise<string[]> => {
  return request.get('/api/system/staff/departments');
};

// 获取职称列表
export const getTitleList = (): Promise<string[]> => {
  return request.get('/api/system/staff/titles');
};

表单验证规则

// 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个字符' },
  ],
};

组件实现骨架

// 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: <Empty description="暂无数据" />,
    },
  };
  
  // 新建人员
  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 (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
      {/* 头部 */}
      <div style={{ 
        padding: '24px 24px 16px 24px',
        borderBottom: '1px solid #f0f0f0',
      }}>
        <Typography.Title level={4} style={{ marginBottom: 16 }}>
          转诊/执行医生和技师名单
        </Typography.Title>
        
        <Tabs
          activeKey={activeTab}
          onChange={handleTabChange}
          items={[
            { key: StaffType.ALL, label: '姓名' },
            { key: StaffType.DOCTOR, label: '医生' },
            { key: StaffType.TECHNICIAN, label: '技师' },
          ]}
        />
      </div>
      
      {/* 内容区 */}
      <div style={{ flex: 1, overflow: 'auto', padding: '24px 24px 0 24px' }}>
        <Table {...tableConfig} />
      </div>
      
      {/* 底部操作栏 */}
      <div style={{
        height: 72,
        borderTop: '1px solid #f0f0f0',
        padding: '16px 24px',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
      }}>
        <Space>
          <Popconfirm
            title={`确定要删除选中的 ${selection.selectedRowKeys.length} 个人员吗?`}
            description="删除后将无法恢复。"
            onConfirm={handleDelete}
            disabled={selection.selectedRowKeys.length === 0}
          >
            <Button
              icon={<DeleteOutlined />}
              disabled={selection.selectedRowKeys.length === 0}
              loading={operations.deleting}
            >
              删除
            </Button>
          </Popconfirm>
          
          <Button
            icon={<EditOutlined />}
            onClick={handleEdit}
            disabled={selection.selectedRowKeys.length !== 1}
          >
            编辑
          </Button>
        </Space>
        
        <Button
          type="primary"
          icon={<PlusOutlined />}
          onClick={handleCreate}
        >
          新建
        </Button>
      </div>
      
      {/* 新建Modal */}
      <Modal
        title="新建人员"
        open={modals.createStaff}
        onOk={handleCreateSubmit}
        onCancel={() => dispatch(closeModal('createStaff'))}
        confirmLoading={operations.creating}
        width={600}
      >
        <Form
          form={createForm}
          layout="vertical"
        >
          <Form.Item name="name" label="姓名" rules={staffFormRules.name}>
            <Input placeholder="请输入姓名" />
          </Form.Item>
          
          <Form.Item name="type" label="人员类型" rules={staffFormRules.type}>
            <Select placeholder="请选择人员类型">
              <Select.Option value={StaffType.DOCTOR}>医生</Select.Option>
              <Select.Option value={StaffType.TECHNICIAN}>技师</Select.Option>
            </Select>
          </Form.Item>
          
          <Form.Item name="staffNumber" label="工号" rules={staffFormRules.staffNumber}>
            <Input placeholder="请输入工号" />
          </Form.Item>
          
          <Form.Item name="department" label="科室" rules={staffFormRules.department}>
            <Input placeholder="请输入科室" />
          </Form.Item>
          
          <Form.Item name="title" label="职称" rules={staffFormRules.title}>
            <Input placeholder="请输入职称(可选)" />
          </Form.Item>
          
          <Form.Item name="phone" label="联系电话" rules={staffFormRules.phone}>
            <Input placeholder="请输入联系电话(可选)" />
          </Form.Item>
          
          <Form.Item name="remark" label="备注" rules={staffFormRules.remark}>
            <Input.TextArea rows={3} placeholder="请输入备注(可选)" />
          </Form.Item>
        </Form>
      </Modal>
      
      {/* 编辑Modal */}
      <Modal
        title="编辑人员"
        open={modals.editStaff}
        onOk={handleEditSubmit}
        onCancel={() => dispatch(closeModal('editStaff'))}
        confirmLoading={operations.updating}
        width={600}
      >
        <Form
          form={editForm}
          layout="vertical"
        >
          <Form.Item name="name" label="姓名" rules={staffFormRules.name}>
            <Input placeholder="请输入姓名" />
          </Form.Item>
          
          <Form.Item name="department" label="科室" rules={staffFormRules.department}>
            <Input placeholder="请输入科室" />
          </Form.Item>
          
          <Form.Item name="title" label="职称" rules={staffFormRules.title}>
            <Input placeholder="请输入职称(可选)" />
          </Form.Item>
          
          <Form.Item name="phone" label="联系电话" rules={staffFormRules.phone}>
            <Input placeholder="请输入联系电话(可选)" />
          </Form.Item>
          
          <Form.Item name="remark" label="备注" rules={staffFormRules.remark}>
            <Input.TextArea rows={3} placeholder="请输入备注(可选)" />
          </Form.Item>
        </Form>
      </Modal>
    </div>
  );
};

export default DoctorsAndTechnicians;

国际化支持

// 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 测试

// 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集成
    • 支持单点登录