CodeSpirit CRUD开发完整指南

概述

本文档通过职工管理(Employee)的实际代码示例,展示如何使用CodeSpirit框架快速开发CRUD功能。该示例来自身份认证系统(IdentityApi),是一个标准的关联型CRUD模块,包含完整的验证逻辑、业务处理和关联关系管理。

最后更新: 2025年12月22日
框架版本: v2.0.0
示例来源: CodeSpirit.IdentityApi - 职工管理模块

image

开发流程概览

graph LR A["1. 创建实体模型"] --> B["2. 创建DTO类"] B --> C["3. 配置AutoMapper"] C --> D["4. 创建服务层"] D --> E["5. 创建控制器"] E --> F["6. 配置数据库"] F --> G["7. 创建迁移"] G --> H["完成"]

示例模块说明

职工管理(Employee)是一个典型的关联型CRUD模块,具有以下特点:

  • ✅ 关联关系管理(部门、用户账号)
  • ✅ 完整的CRUD操作
  • ✅ 业务验证(工号唯一性、部门存在性、身份证格式等)
  • ✅ 多条件查询(关键字、部门、状态、日期范围等)
  • ✅ 表单分组展示(基本信息、联系方式、工作信息等)
  • ✅ 多租户支持
  • ✅ 审计字段自动记录
  • ✅ 软删除支持

1. 创建实体模型

Data/Models目录下创建实体类:

// Data/Models/Employee.cs
using CodeSpirit.Shared.Entities.Interfaces;
using CodeSpirit.MultiTenant.Abstractions;
using System.ComponentModel.DataAnnotations;

namespace CodeSpirit.IdentityApi.Data.Models;

/// <summary>
/// 职工信息
/// </summary>
public class Employee : IFullAuditable, IMultiTenant, IIsActive
{
    /// <summary>
    /// 职工ID
    /// </summary>
    public long Id { get; set; }

    /// <summary>
    /// 租户ID(多租户支持)
    /// </summary>
    [Required]
    [MaxLength(50)]
    public string TenantId { get; set; } = string.Empty;

    /// <summary>
    /// 工号(租户内唯一)
    /// </summary>
    [Required]
    [MaxLength(50)]
    public string EmployeeNo { get; set; } = string.Empty;

    /// <summary>
    /// 姓名
    /// </summary>
    [Required]
    [MaxLength(100)]
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 性别
    /// </summary>
    public Gender Gender { get; set; }

    /// <summary>
    /// 身份证号码
    /// </summary>
    [MaxLength(18)]
    public string? IdNo { get; set; }

    /// <summary>
    /// 出生日期
    /// </summary>
    public DateTime? BirthDate { get; set; }

    /// <summary>
    /// 手机号码
    /// </summary>
    [MaxLength(15)]
    public string? PhoneNumber { get; set; }

    /// <summary>
    /// 电子邮箱
    /// </summary>
    [MaxLength(100)]
    [EmailAddress]
    public string? Email { get; set; }

    /// <summary>
    /// 部门ID
    /// </summary>
    public long? DepartmentId { get; set; }

    /// <summary>
    /// 所属部门(导航属性)
    /// </summary>
    public Department? Department { get; set; }

    /// <summary>
    /// 职位
    /// </summary>
    [MaxLength(100)]
    public string? Position { get; set; }

    /// <summary>
    /// 职级
    /// </summary>
    [MaxLength(50)]
    public string? JobLevel { get; set; }

    /// <summary>
    /// 入职日期
    /// </summary>
    public DateTime? HireDate { get; set; }

    /// <summary>
    /// 离职日期
    /// </summary>
    public DateTime? TerminationDate { get; set; }

    /// <summary>
    /// 在职状态
    /// </summary>
    public EmploymentStatus EmploymentStatus { get; set; }

    /// <summary>
    /// 关联的用户ID
    /// </summary>
    public long? UserId { get; set; }

    /// <summary>
    /// 关联的用户账号(导航属性)
    /// </summary>
    public ApplicationUser? User { get; set; }

    /// <summary>
    /// 紧急联系人
    /// </summary>
    [MaxLength(100)]
    public string? EmergencyContact { get; set; }

    /// <summary>
    /// 紧急联系电话
    /// </summary>
    [MaxLength(15)]
    public string? EmergencyPhone { get; set; }

    /// <summary>
    /// 地址
    /// </summary>
    [MaxLength(500)]
    public string? Address { get; set; }

    /// <summary>
    /// 备注
    /// </summary>
    [MaxLength(1000)]
    public string? Remarks { get; set; }

    /// <summary>
    /// 是否激活
    /// </summary>
    public bool IsActive { get; set; } = true;

    /// <summary>
    /// 头像地址
    /// </summary>
    [MaxLength(255)]
    [DataType(DataType.ImageUrl)]
    public string? AvatarUrl { get; set; }

    // 审计字段(实现IFullAuditable接口)
    public long CreatedBy { get; set; }
    public DateTime CreatedAt { get; set; }
    public long? UpdatedBy { get; set; }
    public DateTime? UpdatedAt { get; set; }
    public long? DeletedBy { get; set; }
    public DateTime? DeletedAt { get; set; }
    public bool IsDeleted { get; set; }
}

说明

  • 实现IFullAuditable接口,自动包含完整的审计字段(创建、更新、删除)
  • 实现IMultiTenant接口,支持多租户数据隔离
  • 实现IIsActive接口,支持激活状态管理
  • 使用long作为主键类型
  • 包含关联关系的导航属性(部门、用户账号)
  • 支持软删除(IsDeleted字段)

2. 创建DTO类

Dtos/Employee目录下创建DTO类:

2.1 EmployeeDto(展示DTO)

// Dtos/Employee/EmployeeDto.cs
using CodeSpirit.Amis.Attributes.Columns;
using CodeSpirit.Core.Attributes;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

/// <summary>
/// 职工数据传输对象
/// </summary>
public class EmployeeDto
{
    /// <summary>
    /// 职工ID
    /// </summary>
    public long Id { get; set; }

    /// <summary>
    /// 工号
    /// </summary>
    [DisplayName("工号")]
    public string EmployeeNo { get; set; } = string.Empty;

    /// <summary>
    /// 姓名
    /// </summary>
    [DisplayName("姓名")]
    [TplColumn(template: "${name}")]
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 头像地址
    /// </summary>
    [DisplayName("头像")]
    [AvatarColumn(Text = "${name}", Src = "${avatarUrl}")]
    [Badge(Animation = true, VisibleOn = "isActive", Level = "info")]
    public string? AvatarUrl { get; set; }

    /// <summary>
    /// 性别
    /// </summary>
    [DisplayName("性别")]
    public Gender Gender { get; set; }

    /// <summary>
    /// 手机号码
    /// </summary>
    [DisplayName("手机号码")]
    public string? PhoneNumber { get; set; }

    /// <summary>
    /// 电子邮箱
    /// </summary>
    [DisplayName("电子邮箱")]
    public string? Email { get; set; }

    /// <summary>
    /// 部门ID
    /// </summary>
    [AmisColumn(Hidden = true)]
    public long? DepartmentId { get; set; }

    /// <summary>
    /// 部门名称
    /// </summary>
    [DisplayName("部门")]
    public string? DepartmentName { get; set; }

    /// <summary>
    /// 职位
    /// </summary>
    [DisplayName("职位")]
    public string? Position { get; set; }

    /// <summary>
    /// 职级
    /// </summary>
    [DisplayName("职级")]
    public string? JobLevel { get; set; }

    /// <summary>
    /// 入职日期
    /// </summary>
    [DisplayName("入职日期")]
    [DateColumn(Format = "YYYY-MM-DD")]
    public DateTime? HireDate { get; set; }

    /// <summary>
    /// 在职状态
    /// </summary>
    [DisplayName("在职状态")]
    public EmploymentStatus EmploymentStatus { get; set; }

    /// <summary>
    /// 是否激活
    /// </summary>
    [DisplayName("是否激活")]
    public bool IsActive { get; set; }

    /// <summary>
    /// 创建时间
    /// </summary>
    [DisplayName("创建时间")]
    [DateColumn(FromNow = true)]
    public DateTime CreatedAt { get; set; }

    /// <summary>
    /// 更新时间
    /// </summary>
    [DisplayName("更新时间")]
    [DateColumn(FromNow = true)]
    public DateTime? UpdatedAt { get; set; }
}

说明

列特性(Columns):用于控制前端表格列的显示和格式

  • AmisColumn:基础列特性,控制列的显示、排序、隐藏等

    • Hidden:是否隐藏列
    • Sortable:是否支持排序
    • Copyable:是否可复制
    • Fixed:是否固定列(left/right/none)
    • StatusMapping:状态映射(支持预定义映射如Boolean、HttpStatusCode等)
  • TplColumn:自定义列显示模板,使用模板语法自定义列内容

    • template:模板字符串,支持变量插值(如${name}
  • AvatarColumn:头像列,显示头像图片

    • Text:头像下方显示的文本
    • Src:头像图片地址
  • DateColumn:日期列,格式化日期显示

    • Format:日期格式(如YYYY-MM-DDYYYY-MM-DD HH:mm
    • FromNow:是否显示相对时间(如"2小时前")
  • IgnoreColumn:忽略列,该字段不在表格中显示

  • TagsColumn:标签列,以标签形式显示数组数据

  • LinkColumn:链接列,显示可点击的链接

  • AmisStatusColumn:状态列,显示状态标签和图标

  • LongTextColumn:长文本列,支持展开/收起

  • ListColumn:列表列,显示列表数据

  • IconColumn:图标列,显示图标

2.2 CreateEmployeeDto(创建DTO)

image

// Dtos/Employee/CreateEmployeeDto.cs
using CodeSpirit.Amis.Attributes.FormFields;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

/// <summary>
/// 创建职工数据传输对象
/// </summary>
[FormGroup("basic", "基本信息", "EmployeeNo,Name,Gender,IdNo,BirthDate", Order = 1)]
[FormGroup("contact", "联系方式", "PhoneNumber,Email,Address", Order = 2)]
[FormGroup("work", "工作信息", "DepartmentId,Position,JobLevel,HireDate,EmploymentStatus", Order = 3)]
[FormGroup("relation", "关联信息", "UserId", Order = 4)]
[FormGroup("emergency", "紧急联系人", "EmergencyContact,EmergencyPhone", Order = 5)]
[FormGroup("other", "其他信息", "AvatarUrl,Remarks,IsActive", Order = 6)]
public class CreateEmployeeDto
{
    /// <summary>
    /// 工号
    /// </summary>
    [Required(ErrorMessage = "工号不能为空")]
    [MaxLength(50, ErrorMessage = "工号长度不能超过50个字符")]
    [DisplayName("工号")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string EmployeeNo { get; set; } = string.Empty;

    /// <summary>
    /// 姓名
    /// </summary>
    [Required(ErrorMessage = "姓名不能为空")]
    [MaxLength(100, ErrorMessage = "姓名长度不能超过100个字符")]
    [DisplayName("姓名")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 性别
    /// </summary>
    [DisplayName("性别")]
    [AmisFormField(ColumnRatio = 6)]
    public Gender Gender { get; set; }

    /// <summary>
    /// 身份证号码
    /// </summary>
    [MaxLength(18, ErrorMessage = "身份证号码长度不能超过18个字符")]
    [DisplayName("身份证号")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? IdNo { get; set; }

    /// <summary>
    /// 出生日期
    /// </summary>
    [DisplayName("出生日期")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? BirthDate { get; set; }

    /// <summary>
    /// 手机号码
    /// </summary>
    [MaxLength(15, ErrorMessage = "手机号码长度不能超过15个字符")]
    [Phone(ErrorMessage = "手机号码格式不正确")]
    [DisplayName("手机号码")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? PhoneNumber { get; set; }

    /// <summary>
    /// 电子邮箱
    /// </summary>
    [MaxLength(100, ErrorMessage = "电子邮箱长度不能超过100个字符")]
    [EmailAddress(ErrorMessage = "电子邮箱格式不正确")]
    [DisplayName("电子邮箱")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Email { get; set; }

    /// <summary>
    /// 部门ID
    /// </summary>
    [DisplayName("部门")]
    [AmisInputTreeField(
        DataSource = "${ROOT_API}/api/identity/Departments/tree",
        LabelField = "name",
        ValueField = "id",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? DepartmentId { get; set; }

    /// <summary>
    /// 职位
    /// </summary>
    [MaxLength(100, ErrorMessage = "职位长度不能超过100个字符")]
    [DisplayName("职位")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Position { get; set; }

    /// <summary>
    /// 职级
    /// </summary>
    [MaxLength(50, ErrorMessage = "职级长度不能超过50个字符")]
    [DisplayName("职级")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? JobLevel { get; set; }

    /// <summary>
    /// 入职日期
    /// </summary>
    [DisplayName("入职日期")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? HireDate { get; set; }

    /// <summary>
    /// 在职状态
    /// </summary>
    [DisplayName("在职状态")]
    [AmisFormField(ColumnRatio = 6)]
    public EmploymentStatus EmploymentStatus { get; set; } = EmploymentStatus.Active;

    /// <summary>
    /// 关联的用户ID
    /// </summary>
    [DisplayName("关联用户")]
    [AmisSelectField(
        Source = "${ROOT_API}/api/identity/Users",
        ValueField = "id",
        LabelField = "name",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? UserId { get; set; }

    /// <summary>
    /// 紧急联系人
    /// </summary>
    [MaxLength(100, ErrorMessage = "紧急联系人长度不能超过100个字符")]
    [DisplayName("紧急联系人")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyContact { get; set; }

    /// <summary>
    /// 紧急联系电话
    /// </summary>
    [MaxLength(15, ErrorMessage = "紧急联系电话长度不能超过15个字符")]
    [Phone(ErrorMessage = "紧急联系电话格式不正确")]
    [DisplayName("紧急联系电话")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyPhone { get; set; }

    /// <summary>
    /// 地址
    /// </summary>
    [MaxLength(500, ErrorMessage = "地址长度不能超过500个字符")]
    [DisplayName("地址")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Address { get; set; }

    /// <summary>
    /// 头像地址
    /// </summary>
    [MaxLength(255, ErrorMessage = "头像地址长度不能超过255个字符")]
    [DisplayName("头像")]
    [AmisInputImageField(
        Receiver = "/file/api/file/images/upload?BucketName=avatar",
        Accept = "image/png,image/jpeg,image/jpg",
        MaxSize = 2097152,
        Multiple = false,
        ColumnRatio = 12
    )]
    public string? AvatarUrl { get; set; }

    /// <summary>
    /// 备注
    /// </summary>
    [MaxLength(1000, ErrorMessage = "备注长度不能超过1000个字符")]
    [DisplayName("备注")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Remarks { get; set; }

    /// <summary>
    /// 是否激活
    /// </summary>
    [DisplayName("是否激活")]
    [AmisFormField(ColumnRatio = 6)]
    public bool IsActive { get; set; } = true;
}

说明

表单特性(FormFields):用于控制前端表单字段的显示和交互

  • FormGroup:表单分组特性,将相关字段组织成组

    • Name:组名称
    • Title:组标题
    • Fields:包含的字段名称(逗号分隔)
    • Order:显示顺序(数值越小越靠前)
    • Mode:显示模式(Normal/Inline/Horizontal)
  • AmisInputTextField:文本输入框

    • ColumnRatio:字段宽度比例(12为全宽,6为半宽)
    • EnableAddOn:是否启用右侧附加组件
    • AddOnLabel:附加组件标签
    • AddOnApi:附加组件API地址
  • AmisInputTreeField:树形选择组件

    • DataSource:数据源URL
    • ValueField:值字段名
    • LabelField:标签字段名
    • Multiple:是否多选
    • Searchable:是否可搜索
    • ShowOutline:是否显示轮廓
    • SubmitOnChange:选择后是否自动提交
  • AmisSelectField:下拉选择组件

    • Source:数据源URL
    • ValueField:值字段名
    • LabelField:标签字段名
    • Multiple:是否多选
    • Searchable:是否可搜索
    • Clearable:是否可清除
  • AmisInputImageField:图片上传组件

    • Receiver:上传接口地址
    • Accept:接受的文件类型
    • MaxSize:最大文件大小(字节)
    • Multiple:是否支持多文件
  • AmisDateFieldAttribute:日期选择组件

    • Format:日期格式
    • Placeholder:占位符
    • MinDate:最小日期
    • MaxDate:最大日期
  • AmisTextareaField:多行文本输入框

    • MaxLength:最大长度
    • ShowCounter:是否显示字符计数
    • Rows:行数

通用属性

  • ColumnRatio:字段宽度比例(12为全宽,6为半宽,4为1/3宽)
  • Required:是否必填
  • Placeholder:占位符文本
  • Disabled:是否禁用
  • VisibleOn:显示条件表达式
  • DisabledOn:禁用条件表达式

2.3 UpdateEmployeeDto(更新DTO)

// Dtos/Employee/UpdateEmployeeDto.cs
using CodeSpirit.Amis.Attributes.FormFields;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

/// <summary>
/// 更新职工数据传输对象
/// </summary>
[FormGroup("basic", "基本信息", "EmployeeNo,Name,Gender,IdNo,BirthDate", Order = 1)]
[FormGroup("contact", "联系方式", "PhoneNumber,Email,Address", Order = 2)]
[FormGroup("work", "工作信息", "DepartmentId,Position,JobLevel,HireDate,TerminationDate,EmploymentStatus", Order = 3)]
[FormGroup("relation", "关联信息", "UserId", Order = 4)]
[FormGroup("emergency", "紧急联系人", "EmergencyContact,EmergencyPhone", Order = 5)]
[FormGroup("other", "其他信息", "AvatarUrl,Remarks,IsActive", Order = 6)]
public class UpdateEmployeeDto
{
    /// <summary>
    /// 工号
    /// </summary>
    [Required(ErrorMessage = "工号不能为空")]
    [MaxLength(50, ErrorMessage = "工号长度不能超过50个字符")]
    [DisplayName("工号")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string EmployeeNo { get; set; } = string.Empty;

    /// <summary>
    /// 姓名
    /// </summary>
    [Required(ErrorMessage = "姓名不能为空")]
    [MaxLength(100, ErrorMessage = "姓名长度不能超过100个字符")]
    [DisplayName("姓名")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string Name { get; set; } = string.Empty;

    /// <summary>
    /// 性别
    /// </summary>
    [DisplayName("性别")]
    [AmisFormField(ColumnRatio = 6)]
    public Gender Gender { get; set; }

    /// <summary>
    /// 身份证号码
    /// </summary>
    [MaxLength(18, ErrorMessage = "身份证号码长度不能超过18个字符")]
    [DisplayName("身份证号")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? IdNo { get; set; }

    /// <summary>
    /// 出生日期
    /// </summary>
    [DisplayName("出生日期")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? BirthDate { get; set; }

    /// <summary>
    /// 手机号码
    /// </summary>
    [MaxLength(15, ErrorMessage = "手机号码长度不能超过15个字符")]
    [Phone(ErrorMessage = "手机号码格式不正确")]
    [DisplayName("手机号码")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? PhoneNumber { get; set; }

    /// <summary>
    /// 电子邮箱
    /// </summary>
    [MaxLength(100, ErrorMessage = "电子邮箱长度不能超过100个字符")]
    [EmailAddress(ErrorMessage = "电子邮箱格式不正确")]
    [DisplayName("电子邮箱")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Email { get; set; }

    /// <summary>
    /// 部门ID
    /// </summary>
    [DisplayName("部门")]
    [AmisInputTreeField(
        DataSource = "${ROOT_API}/api/identity/Departments/tree",
        LabelField = "name",
        ValueField = "id",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? DepartmentId { get; set; }

    /// <summary>
    /// 职位
    /// </summary>
    [MaxLength(100, ErrorMessage = "职位长度不能超过100个字符")]
    [DisplayName("职位")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? Position { get; set; }

    /// <summary>
    /// 职级
    /// </summary>
    [MaxLength(50, ErrorMessage = "职级长度不能超过50个字符")]
    [DisplayName("职级")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? JobLevel { get; set; }

    /// <summary>
    /// 入职日期
    /// </summary>
    [DisplayName("入职日期")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? HireDate { get; set; }

    /// <summary>
    /// 离职日期
    /// </summary>
    [DisplayName("离职日期")]
    [AmisDateFieldAttribute(ColumnRatio = 6)]
    public DateTime? TerminationDate { get; set; }

    /// <summary>
    /// 在职状态
    /// </summary>
    [DisplayName("在职状态")]
    [AmisFormField(ColumnRatio = 12)]
    public EmploymentStatus EmploymentStatus { get; set; }

    /// <summary>
    /// 关联的用户ID
    /// </summary>
    [DisplayName("关联用户")]
    [AmisSelectField(
        Source = "${ROOT_API}/api/identity/Users",
        ValueField = "id",
        LabelField = "name",
        Multiple = false,
        Searchable = true,
        ColumnRatio = 12
    )]
    public long? UserId { get; set; }

    /// <summary>
    /// 紧急联系人
    /// </summary>
    [MaxLength(100, ErrorMessage = "紧急联系人长度不能超过100个字符")]
    [DisplayName("紧急联系人")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyContact { get; set; }

    /// <summary>
    /// 紧急联系电话
    /// </summary>
    [MaxLength(15, ErrorMessage = "紧急联系电话长度不能超过15个字符")]
    [Phone(ErrorMessage = "紧急联系电话格式不正确")]
    [DisplayName("紧急联系电话")]
    [AmisInputTextField(ColumnRatio = 6)]
    public string? EmergencyPhone { get; set; }

    /// <summary>
    /// 地址
    /// </summary>
    [MaxLength(500, ErrorMessage = "地址长度不能超过500个字符")]
    [DisplayName("地址")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Address { get; set; }

    /// <summary>
    /// 头像地址
    /// </summary>
    [MaxLength(255, ErrorMessage = "头像地址长度不能超过255个字符")]
    [DisplayName("头像")]
    [AmisInputImageField(
        Receiver = "/file/api/file/images/upload?BucketName=avatar",
        Accept = "image/png,image/jpeg,image/jpg",
        MaxSize = 2097152,
        Multiple = false,
        ColumnRatio = 12
    )]
    public string? AvatarUrl { get; set; }

    /// <summary>
    /// 备注
    /// </summary>
    [MaxLength(1000, ErrorMessage = "备注长度不能超过1000个字符")]
    [DisplayName("备注")]
    [AmisTextareaField(ColumnRatio = 12)]
    public string? Remarks { get; set; }

    /// <summary>
    /// 是否激活
    /// </summary>
    [DisplayName("是否激活")]
    [AmisFormField(ColumnRatio = 6)]
    public bool IsActive { get; set; }
}

2.4 EmployeeQueryDto(查询DTO)

// Dtos/Employee/EmployeeQueryDto.cs
using CodeSpirit.Amis.Attributes.FormFields;
using CodeSpirit.Core.Dtos;
using CodeSpirit.IdentityApi.Data.Models;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Dtos.Employee;

/// <summary>
/// 职工查询数据传输对象
/// </summary>
public class EmployeeQueryDto : QueryDtoBase
{
    /// <summary>
    /// 关键字搜索(姓名、工号、身份证、手机、邮箱)
    /// </summary>
    [DisplayName("关键字")]
    public string? Keywords { get; set; }

    /// <summary>
    /// 是否激活
    /// </summary>
    [DisplayName("是否激活")]
    public bool? IsActive { get; set; }

    /// <summary>
    /// 性别筛选
    /// </summary>
    [DisplayName("性别")]
    public Gender? Gender { get; set; }

    /// <summary>
    /// 部门ID筛选
    /// </summary>
    [DisplayName("部门")]
    [AmisInputTreeField(
        DataSource = "${ROOT_API}/api/identity/Departments/tree",
        Multiple = false,
        JoinValues = true,
        ExtractValue = false,
        ShowOutline = true,
        LabelField = "name",
        ValueField = "id",
        Required = false,
        Clearable = true,
        SubmitOnChange = true,
        HeightAuto = true,
        SelectFirst = false,
        InputOnly = true,
        ShowIcon = true
    )]
    [PageAside()]
    public long? DepartmentId { get; set; }

    /// <summary>
    /// 在职状态筛选
    /// </summary>
    [DisplayName("在职状态")]
    public EmploymentStatus? EmploymentStatus { get; set; }

    /// <summary>
    /// 入职日期范围
    /// </summary>
    [DisplayName("入职日期")]
    public DateTime[]? HireDate { get; set; }

    /// <summary>
    /// 职位
    /// </summary>
    [DisplayName("职位")]
    public string? Position { get; set; }

    /// <summary>
    /// 职级
    /// </summary>
    [DisplayName("职级")]
    public string? JobLevel { get; set; }
}

说明

查询DTO特性

  • QueryDtoBase:基础查询DTO,提供了PagePerPageOrderByOrderDirKeywords等分页和排序属性

  • AmisInputTreeField:树形选择组件(用于查询表单)

    • DataSource:数据源URL
    • SubmitOnChange:选择后自动提交查询
    • Searchable:是否可搜索
    • Clearable:是否可清除
    • ShowOutline:是否显示轮廓
    • HeightAuto:高度自适应
  • PageAside()特性:标记该字段在页面侧边栏显示

    • 标记了此特性的字段会自动从主查询表单中排除,避免重复显示
    • 特别适用于树形选择、分类筛选等需要独立展示的字段
    • 侧边栏字段的变化会自动触发主内容区域的查询刷新(通过SubmitOnChange配置)
    • 可以配置侧边栏的位置(左侧/右侧)、宽度、是否固定等属性

查询字段特性

  • 查询DTO中的字段可以使用表单特性(如AmisInputTreeFieldAmisSelectField等)来配置查询表单的显示
  • 支持多条件组合查询,提升查询灵活性
  • 枚举类型字段会自动生成下拉选择组件
  • 日期类型字段可以使用AmisDateFieldAttribute配置日期范围选择

3. 配置AutoMapper映射

MappingProfiles目录下创建映射配置:

// MappingProfiles/EmployeeProfile.cs
using AutoMapper;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.Shared.Extensions;

namespace CodeSpirit.IdentityApi.MappingProfiles;

/// <summary>
/// 职工映射配置
/// </summary>
public class EmployeeProfile : Profile
{
    /// <summary>
    /// 构造函数
    /// </summary>
    public EmployeeProfile()
    {
        // 使用扩展方法配置基本CRUD映射(自动处理Include导航属性)
        this.ConfigureBaseCRUDIMappings<
            Employee, 
            EmployeeDto, 
            long, 
            CreateEmployeeDto, 
            UpdateEmployeeDto,
            CreateEmployeeDto>();
            
        // 自定义映射:映射部门名称和用户名
        CreateMap<Employee, EmployeeDto>()
            .ForMember(dest => dest.DepartmentName, opt => opt.MapFrom(src => src.Department != null ? src.Department.Name : null))
            .ForMember(dest => dest.UserName, opt => opt.MapFrom(src => src.User != null ? src.User.UserName : null));
    }
}

说明

  • ConfigureBaseCRUDIMappings扩展方法自动配置基本的CRUD映射
  • 使用ForMember自定义字段映射逻辑,将导航属性映射到DTO
  • 支持多个DTO类型的映射配置

4. 创建服务接口和实现

4.1 服务接口

// Services/IEmployeeService.cs
using CodeSpirit.Core;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.Shared.Services;

namespace CodeSpirit.IdentityApi.Services;

/// <summary>
/// 职工服务接口
/// </summary>
public interface IEmployeeService : IBaseCRUDIService<Employee, EmployeeDto, long, CreateEmployeeDto, UpdateEmployeeDto, EmployeeBatchImportItemDto>, IScopedDependency
{
    /// <summary>
    /// 获取职工列表(分页)
    /// </summary>
    /// <param name="queryDto">查询条件</param>
    /// <returns>职工分页列表</returns>
    Task<PageList<EmployeeDto>> GetEmployeesAsync(EmployeeQueryDto queryDto);

    /// <summary>
    /// 根据部门获取职工列表
    /// </summary>
    /// <param name="departmentId">部门ID</param>
    /// <param name="includeSubDepartments">是否包含子部门</param>
    /// <returns>职工列表</returns>
    Task<List<EmployeeDto>> GetEmployeesByDepartmentAsync(long departmentId, bool includeSubDepartments = false);

    /// <summary>
    /// 设置职工激活状态
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <param name="isActive">是否激活</param>
    Task SetActiveStatusAsync(long id, bool isActive);

    /// <summary>
    /// 转移职工到新部门
    /// </summary>
    /// <param name="employeeId">职工ID</param>
    /// <param name="newDepartmentId">新部门ID</param>
    Task TransferEmployeeAsync(long employeeId, long? newDepartmentId);

    /// <summary>
    /// 办理职工离职
    /// </summary>
    /// <param name="employeeId">职工ID</param>
    /// <param name="terminationDate">离职日期</param>
    Task TerminateEmployeeAsync(long employeeId, DateTime terminationDate);

    /// <summary>
    /// 验证工号是否唯一
    /// </summary>
    /// <param name="employeeNo">工号</param>
    /// <param name="excludeId">排除的职工ID(用于更新时验证)</param>
    /// <returns>是否唯一</returns>
    Task<bool> IsEmployeeNoUniqueAsync(string employeeNo, long? excludeId = null);
}

4.2 服务实现

// Services/EmployeeService.cs
using AutoMapper;
using CodeSpirit.Core;
using CodeSpirit.Core.IdGenerator;
using CodeSpirit.IdentityApi.Data;
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.IdentityApi.Utilities;
using CodeSpirit.Shared.Repositories;
using CodeSpirit.Shared.Services;
using CodeSpirit.Shared.Dtos.Common;
using LinqKit;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;

namespace CodeSpirit.IdentityApi.Services;

/// <summary>
/// 职工服务实现
/// </summary>
public class EmployeeService : BaseCRUDIService<Employee, EmployeeDto, long, CreateEmployeeDto, UpdateEmployeeDto, EmployeeBatchImportItemDto>, IEmployeeService
{
    private readonly IRepository<Employee> _employeeRepository;
    private readonly IRepository<Department> _departmentRepository;
    private readonly IRepository<ApplicationUser> _userRepository;
    private readonly ILogger<EmployeeService> _logger;
    private readonly IIdGenerator _idGenerator;
    private readonly ICurrentUser _currentUser;
    private readonly ApplicationDbContext _dbContext;
    private readonly IDepartmentService _departmentService;
    private readonly UserManager<ApplicationUser> _userManager;

    /// <summary>
    /// 构造函数
    /// </summary>
    public EmployeeService(
        IRepository<Employee> employeeRepository,
        IRepository<Department> departmentRepository,
        IRepository<ApplicationUser> userRepository,
        IMapper mapper,
        ILogger<EmployeeService> logger,
        IIdGenerator idGenerator,
        ICurrentUser currentUser,
        ApplicationDbContext dbContext,
        IDepartmentService departmentService,
        UserManager<ApplicationUser> userManager,
        EnhancedBatchImportHelper<EmployeeBatchImportItemDto> importHelper)
        : base(employeeRepository, mapper, importHelper)
    {
        _employeeRepository = employeeRepository;
        _departmentRepository = departmentRepository;
        _userRepository = userRepository;
        _logger = logger;
        _idGenerator = idGenerator;
        _currentUser = currentUser;
        _dbContext = dbContext;
        _departmentService = departmentService;
        _userManager = userManager;
    }

    /// <summary>
    /// 获取职工列表(分页)
    /// </summary>
    public async Task<PageList<EmployeeDto>> GetEmployeesAsync(EmployeeQueryDto queryDto)
    {
        var predicate = PredicateBuilder.New<Employee>(true);

        // 应用搜索关键词过滤
        if (!string.IsNullOrWhiteSpace(queryDto.Keywords))
        {
            string searchLower = queryDto.Keywords.ToLower();
            predicate = predicate.Or(e => e.Name.ToLower().Contains(searchLower));
            predicate = predicate.Or(e => e.EmployeeNo.ToLower().Contains(searchLower));
            predicate = predicate.Or(e => e.IdNo.Contains(queryDto.Keywords));
            predicate = predicate.Or(e => e.PhoneNumber.Contains(queryDto.Keywords));
            predicate = predicate.Or(e => e.Email.ToLower().Contains(searchLower));
        }

        // 应用其他过滤条件
        if (queryDto.IsActive.HasValue)
        {
            predicate = predicate.And(e => e.IsActive == queryDto.IsActive.Value);
        }

        if (queryDto.Gender.HasValue)
        {
            predicate = predicate.And(e => e.Gender == queryDto.Gender.Value);
        }

        if (queryDto.DepartmentId.HasValue)
        {
            predicate = predicate.And(e => e.DepartmentId == queryDto.DepartmentId.Value);
        }

        if (queryDto.EmploymentStatus.HasValue)
        {
            predicate = predicate.And(e => e.EmploymentStatus == queryDto.EmploymentStatus.Value);
        }

        if (!string.IsNullOrWhiteSpace(queryDto.Position))
        {
            predicate = predicate.And(e => e.Position == queryDto.Position);
        }

        if (!string.IsNullOrWhiteSpace(queryDto.JobLevel))
        {
            predicate = predicate.And(e => e.JobLevel == queryDto.JobLevel);
        }

        if (queryDto.HireDate != null && queryDto.HireDate.Length == 2)
        {
            predicate = predicate.And(e => e.HireDate >= queryDto.HireDate[0]);
            predicate = predicate.And(e => e.HireDate <= queryDto.HireDate[1]);
        }

        // 创建查询
        var query = _employeeRepository.CreateQuery()
            .Include(e => e.Department)
            .Include(e => e.User)
            .Where(predicate);

        // 执行分页查询
        var totalCount = await query.CountAsync();
        var employees = await query
            .OrderByDescending(e => e.CreatedAt)
            .Skip((queryDto.Page - 1) * queryDto.PerPage)
            .Take(queryDto.PerPage)
            .ToListAsync();

        // 映射到DTO
        var employeeDtos = Mapper.Map<List<EmployeeDto>>(employees);

        // 设置关联数据
        foreach (var dto in employeeDtos)
        {
            var employee = employees.First(e => e.Id == dto.Id);
            dto.DepartmentName = employee.Department?.Name;
            dto.UserName = employee.User?.UserName;
        }

        return new PageList<EmployeeDto>(employeeDtos, totalCount);
    }

    /// <summary>
    /// 根据部门获取职工列表
    /// </summary>
    public async Task<List<EmployeeDto>> GetEmployeesByDepartmentAsync(long departmentId, bool includeSubDepartments = false)
    {
        var departmentIds = new List<long> { departmentId };
        
        if (includeSubDepartments)
        {
            var subDepartments = await _departmentService.GetSubDepartmentsAsync(departmentId);
            departmentIds.AddRange(subDepartments.Select(d => d.Id));
        }

        var employees = await _employeeRepository.CreateQuery()
            .Include(e => e.Department)
            .Include(e => e.User)
            .Where(e => departmentIds.Contains(e.DepartmentId ?? 0))
            .ToListAsync();

        return Mapper.Map<List<EmployeeDto>>(employees);
    }

    /// <summary>
    /// 设置职工激活状态
    /// </summary>
    public async Task SetActiveStatusAsync(long id, bool isActive)
    {
        var employee = await _employeeRepository.GetByIdAsync(id);
        if (employee == null)
        {
            throw new AppServiceException(404, "职工不存在");
        }

        employee.IsActive = isActive;
        await _employeeRepository.UpdateAsync(employee);
    }

    /// <summary>
    /// 转移职工到新部门
    /// </summary>
    public async Task TransferEmployeeAsync(long employeeId, long? newDepartmentId)
    {
        var employee = await _employeeRepository.GetByIdAsync(employeeId);
        if (employee == null)
        {
            throw new AppServiceException(404, "职工不存在");
        }

        if (newDepartmentId.HasValue)
        {
            var departmentExists = await _departmentRepository.ExistsAsync(d => d.Id == newDepartmentId.Value);
            if (!departmentExists)
            {
                throw new AppServiceException(400, "部门不存在");
            }
        }

        employee.DepartmentId = newDepartmentId;
        await _employeeRepository.UpdateAsync(employee);
    }

    /// <summary>
    /// 办理职工离职
    /// </summary>
    public async Task TerminateEmployeeAsync(long employeeId, DateTime terminationDate)
    {
        var employee = await _employeeRepository.GetByIdAsync(employeeId);
        if (employee == null)
        {
            throw new AppServiceException(404, "职工不存在");
        }

        employee.EmploymentStatus = EmploymentStatus.Resigned;
        employee.TerminationDate = terminationDate;
        employee.IsActive = false;
        
        await _employeeRepository.UpdateAsync(employee);
    }

    /// <summary>
    /// 验证工号是否唯一
    /// </summary>
    public async Task<bool> IsEmployeeNoUniqueAsync(string employeeNo, long? excludeId = null)
    {
        var query = _employeeRepository.CreateQuery()
            .Where(e => e.EmployeeNo == employeeNo && e.TenantId == _currentUser.TenantId);

        if (excludeId.HasValue)
        {
            query = query.Where(e => e.Id != excludeId.Value);
        }

        return !await query.AnyAsync();
    }

    /// <summary>
    /// 验证创建DTO
    /// </summary>
    protected override async Task ValidateCreateDto(CreateEmployeeDto createDto)
    {
        await base.ValidateCreateDto(createDto);

        // 验证工号唯一性
        bool isUnique = await IsEmployeeNoUniqueAsync(createDto.EmployeeNo);
        if (!isUnique)
        {
            throw new AppServiceException(400, $"工号 {createDto.EmployeeNo} 已存在,请使用其他工号");
        }

        // 验证部门是否存在
        if (createDto.DepartmentId.HasValue)
        {
            var departmentExists = await _departmentRepository.ExistsAsync(d => d.Id == createDto.DepartmentId.Value);
            if (!departmentExists)
            {
                throw new AppServiceException(400, "部门不存在");
            }
        }

        // 验证用户是否存在(如果指定了用户ID)
        if (createDto.UserId.HasValue)
        {
            var userExists = await _userRepository.ExistsAsync(u => u.Id == createDto.UserId.Value);
            if (!userExists)
            {
                throw new AppServiceException(400, "用户不存在");
            }
        }
    }

    /// <summary>
    /// 验证更新DTO
    /// </summary>
    protected override async Task ValidateUpdateDto(long id, UpdateEmployeeDto updateDto)
    {
        await base.ValidateUpdateDto(id, updateDto);

        // 验证工号唯一性(排除当前记录)
        bool isUnique = await IsEmployeeNoUniqueAsync(updateDto.EmployeeNo, id);
        if (!isUnique)
        {
            throw new AppServiceException(400, $"工号 {updateDto.EmployeeNo} 已存在,请使用其他工号");
        }

        // 验证部门是否存在
        if (updateDto.DepartmentId.HasValue)
        {
            var departmentExists = await _departmentRepository.ExistsAsync(d => d.Id == updateDto.DepartmentId.Value);
            if (!departmentExists)
            {
                throw new AppServiceException(400, "部门不存在");
            }
        }

        // 验证用户是否存在(如果指定了用户ID)
        if (updateDto.UserId.HasValue)
        {
            var userExists = await _userRepository.ExistsAsync(u => u.Id == updateDto.UserId.Value);
            if (!userExists)
            {
                throw new AppServiceException(400, "用户不存在");
            }
        }
    }

    /// <summary>
    /// 创建实体前的处理
    /// </summary>
    protected override async Task<Employee> OnCreating(CreateEmployeeDto createDto)
    {
        var employee = await base.OnCreating(createDto);
        
        // 设置租户ID
        employee.TenantId = _currentUser.TenantId;
        
        // 生成ID(如果需要)
        if (employee.Id == 0)
        {
            employee.Id = await _idGenerator.GenerateIdAsync();
        }

        return employee;
    }
}

说明

  • 继承自BaseCRUDIService,自动获得标准的CRUD方法和批量导入功能
  • 实现IScopedDependency接口,服务会自动注册
  • 重写ValidateCreateDtoValidateUpdateDto方法实现业务验证(工号唯一性、部门存在性等)
  • 重写OnCreating方法设置租户ID和生成ID
  • 使用LinqKitPredicateBuilder构建动态查询条件
  • 提供额外的业务方法(设置激活状态、转移部门、办理离职等)

5. 创建控制器

Controllers目录下创建控制器:

// Controllers/EmployeesController.cs
using CodeSpirit.Core;
using CodeSpirit.Core.Attributes;
using CodeSpirit.Core.Dtos;
using CodeSpirit.Core.Enums;
using CodeSpirit.IdentityApi.Dtos.Employee;
using CodeSpirit.IdentityApi.Services;
using CodeSpirit.Shared.Dtos.Common;
using Microsoft.AspNetCore.Mvc;
using System.ComponentModel;

namespace CodeSpirit.IdentityApi.Controllers;

/// <summary>
/// 职工管理控制器
/// </summary>
[DisplayName("职工管理")]
[Navigation(Icon = "fa-solid fa-user-tie", PlatformType = PlatformType.Tenant)]
public class EmployeesController : ApiControllerBase
{
    private readonly IEmployeeService _employeeService;

    /// <summary>
    /// 构造函数
    /// </summary>
    public EmployeesController(IEmployeeService employeeService)
    {
        _employeeService = employeeService;
    }

    /// <summary>
    /// 获取职工列表
    /// </summary>
    /// <param name="queryDto">查询条件</param>
    /// <returns>职工列表结果</returns>
    [HttpGet]
    [DisplayName("获取职工列表")]
    public async Task<ActionResult<ApiResponse<PageList<EmployeeDto>>>> GetEmployees([FromQuery] EmployeeQueryDto queryDto)
    {
        var employees = await _employeeService.GetEmployeesAsync(queryDto);
        return SuccessResponse(employees);
    }

    /// <summary>
    /// 根据部门获取职工列表
    /// </summary>
    /// <param name="departmentId">部门ID</param>
    /// <param name="includeSubDepartments">是否包含子部门</param>
    /// <returns>职工列表</returns>
    [HttpGet("department/{departmentId}")]
    [DisplayName("根据部门获取职工")]
    public async Task<ActionResult<ApiResponse<List<EmployeeDto>>>> GetEmployeesByDepartment(
        long departmentId, 
        [FromQuery] bool includeSubDepartments = false)
    {
        var employees = await _employeeService.GetEmployeesByDepartmentAsync(departmentId, includeSubDepartments);
        return SuccessResponse(employees);
    }

    /// <summary>
    /// 获取职工详情
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <returns>职工详细信息</returns>
    [HttpGet("{id:long}")]
    [DisplayName("获取职工详情")]
    public async Task<ActionResult<ApiResponse<EmployeeDto>>> GetEmployee(long id)
    {
        var employee = await _employeeService.GetAsync(id);
        return SuccessResponse(employee);
    }

    /// <summary>
    /// 创建职工
    /// </summary>
    /// <param name="createDto">创建职工请求数据</param>
    /// <returns>创建的职工信息</returns>
    [HttpPost]
    [DisplayName("创建职工")]
    public async Task<ActionResult<ApiResponse<EmployeeDto>>> CreateEmployee(CreateEmployeeDto createDto)
    {
        ArgumentNullException.ThrowIfNull(createDto);
        var employeeDto = await _employeeService.CreateAsync(createDto);
        return SuccessResponse(employeeDto);
    }

    /// <summary>
    /// 更新职工
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <param name="updateDto">更新职工请求数据</param>
    /// <returns>更新操作结果</returns>
    [HttpPut("{id:long}")]
    [DisplayName("更新职工")]
    public async Task<ActionResult<ApiResponse>> UpdateEmployee(long id, UpdateEmployeeDto updateDto)
    {
        await _employeeService.UpdateAsync(id, updateDto);
        return SuccessResponse();
    }

    /// <summary>
    /// 删除职工
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <returns>删除操作结果</returns>
    [HttpDelete("{id:long}")]
    [Operation("删除", "ajax", null, "确定要删除此职工吗?")]
    [DisplayName("删除职工")]
    public async Task<ActionResult<ApiResponse>> DeleteEmployee(long id)
    {
        await _employeeService.DeleteAsync(id);
        return SuccessResponse();
    }

    /// <summary>
    /// 批量删除职工
    /// </summary>
    /// <param name="request">批量删除请求</param>
    /// <returns>批量删除操作结果</returns>
    [HttpPost("batch-delete")]
    [Operation("批量删除", "ajax", null, "确定要批量删除选中的职工吗?", isBulkOperation: true)]
    [DisplayName("批量删除职工")]
    public async Task<ActionResult<ApiResponse>> BatchDeleteEmployees([FromBody] BatchOperationDto<long> request)
    {
        ArgumentNullException.ThrowIfNull(request);
        (int successCount, List<long> failedIds) = await _employeeService.BatchDeleteAsync(request.Ids);
        
        return failedIds.Any()
            ? SuccessResponse($"成功删除 {successCount} 个职工,但以下职工删除失败: {string.Join(", ", failedIds)}")
            : SuccessResponse($"成功删除 {successCount} 个职工!");
    }

    /// <summary>
    /// 设置职工激活状态
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <param name="isActive">是否激活</param>
    /// <returns>操作结果</returns>
    [HttpPut("{id:long}/active")]
    [DisplayName("设置激活状态")]
    public async Task<ActionResult<ApiResponse>> SetActiveStatus(long id, [FromBody] bool isActive)
    {
        await _employeeService.SetActiveStatusAsync(id, isActive);
        return SuccessResponse();
    }

    /// <summary>
    /// 转移职工到新部门
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <param name="request">转移请求</param>
    /// <returns>操作结果</returns>
    [HttpPut("{id:long}/transfer")]
    [DisplayName("转移部门")]
    public async Task<ActionResult<ApiResponse>> TransferEmployee(long id, [FromBody] TransferEmployeeRequest request)
    {
        await _employeeService.TransferEmployeeAsync(id, request.DepartmentId);
        return SuccessResponse();
    }

    /// <summary>
    /// 办理职工离职
    /// </summary>
    /// <param name="id">职工ID</param>
    /// <param name="request">离职请求</param>
    /// <returns>操作结果</returns>
    [HttpPut("{id:long}/terminate")]
    [DisplayName("办理离职")]
    public async Task<ActionResult<ApiResponse>> TerminateEmployee(long id, [FromBody] TerminateEmployeeRequest request)
    {
        await _employeeService.TerminateEmployeeAsync(id, request.TerminationDate);
        return SuccessResponse();
    }
}

/// <summary>
/// 转移职工请求
/// </summary>
public class TransferEmployeeRequest
{
    public long? DepartmentId { get; set; }
}

/// <summary>
/// 离职请求
/// </summary>
public class TerminateEmployeeRequest
{
    public DateTime TerminationDate { get; set; }
}

说明

  • 继承自ApiControllerBase,自动获得统一的响应格式和异常处理
  • DisplayName特性用于前端界面显示
  • Navigation特性用于添加到导航菜单
  • Operation特性用于配置操作按钮(删除确认对话框)
  • 使用SuccessResponse方法返回统一的成功响应
  • 提供额外的业务操作接口(设置激活状态、转移部门、办理离职等)

6. 配置数据库上下文

Data目录下的DbContext中添加实体:

// Data/ApplicationDbContext.cs
using CodeSpirit.IdentityApi.Data.Models;
using CodeSpirit.Shared.Data;
using Microsoft.EntityFrameworkCore;

namespace CodeSpirit.IdentityApi.Data;

/// <summary>
/// 身份认证系统数据库上下文 - 支持多租户和多数据库
/// </summary>
public class ApplicationDbContext : MultiDatabaseDbContextBase
{
    /// <summary>
    /// 职工
    /// </summary>
    public DbSet<Employee> Employees => Set<Employee>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 配置Employee实体
        modelBuilder.Entity<Employee>(entity =>
        {
            entity.ToTable(nameof(Employee));
            entity.Property(e => e.Id).ValueGeneratedNever();

            // 租户感知的工号复合唯一索引:同一租户内工号唯一
            entity.HasIndex(e => new { e.TenantId, e.EmployeeNo })
                .IsUnique()
                .HasDatabaseName("IX_Employee_TenantId_EmployeeNo");

            // 索引 DepartmentId,提高查询部门员工的性能
            entity.HasIndex(e => e.DepartmentId)
                .HasDatabaseName("IX_Employee_DepartmentId");

            // 索引 UserId,提高查询用户关联的性能
            entity.HasIndex(e => e.UserId)
                .HasDatabaseName("IX_Employee_UserId");

            // 索引 IsActive,提高按状态过滤的性能
            entity.HasIndex(e => e.IsActive)
                .HasDatabaseName("IX_Employee_IsActive");

            // 索引 EmploymentStatus,提高按在职状态过滤的性能
            entity.HasIndex(e => e.EmploymentStatus)
                .HasDatabaseName("IX_Employee_EmploymentStatus");

            // 配置与部门的关系
            entity.HasOne(e => e.Department)
                .WithMany()
                .HasForeignKey(e => e.DepartmentId)
                .OnDelete(DeleteBehavior.SetNull);

            // 配置与用户的关系
            entity.HasOne(e => e.User)
                .WithMany()
                .HasForeignKey(e => e.UserId)
                .OnDelete(DeleteBehavior.SetNull);
        });
    }
}

说明

  • 继承自MultiDatabaseDbContextBase,支持MySQL和SQL Server
  • 配置表名、主键、字段长度等
  • 配置复合唯一索引(租户ID + 工号),确保同一租户内工号唯一
  • 配置关联关系的级联删除策略(SetNull表示删除部门或用户时,职工记录保留但关联字段设为null)
  • 添加必要的索引提升查询性能

7. 服务注册

CodeSpirit框架通过标记接口自动注册服务,无需手动注册:

// IEmployeeService接口继承了IScopedDependency接口
public interface IEmployeeService : IBaseCRUDIService<...>, IScopedDependency
{
    // ...
}

说明

  • 服务接口继承IScopedDependency接口,服务会自动注册为Scoped生命周期
  • 框架会自动扫描并注册所有标记接口的服务
  • 无需在Program.cs中手动注册

8. 创建数据库迁移

CodeSpirit框架支持多数据库架构,迁移文件按数据库类型分离存储。创建迁移时必须指定迁移目录参数。

# 进入IdentityApi项目目录
cd Src/ApiServices/CodeSpirit.IdentityApi

# 创建迁移(根据数据库类型选择)
# MySQL - 迁移文件将保存到 Migrations/MySql/ 目录
dotnet ef migrations add AddEmployees --context MySqlApplicationDbContext --output-dir Migrations/MySql

# SQL Server - 迁移文件将保存到 Migrations/SqlServer/ 目录
dotnet ef migrations add AddEmployees --context SqlServerApplicationDbContext --output-dir Migrations/SqlServer

# 应用迁移
dotnet ef database update --context MySqlApplicationDbContext
# 或
dotnet ef database update --context SqlServerApplicationDbContext

迁移目录结构

Src/ApiServices/CodeSpirit.IdentityApi/
├── Migrations/
│   ├── MySql/                          # MySQL迁移文件
│   │   ├── 20251222_AddEmployees.cs
│   │   ├── 20251222_AddEmployees.Designer.cs
│   │   └── MySqlApplicationDbContextModelSnapshot.cs
│   └── SqlServer/                      # SQL Server迁移文件
│       ├── 20251222_AddEmployees.cs
│       ├── 20251222_AddEmployees.Designer.cs
│       └── SqlServerApplicationDbContextModelSnapshot.cs

说明

  • --output-dir参数用于指定迁移文件的输出目录
  • MySQL迁移文件必须保存到Migrations/MySql/目录
  • SQL Server迁移文件必须保存到Migrations/SqlServer/目录
  • 每个数据库类型都有独立的ModelSnapshot.cs文件
  • 这样可以确保不同数据库类型的迁移文件互不干扰

功能特性

通过以上步骤,您已经完成了一个完整的CRUD功能开发。CodeSpirit框架会自动提供以下功能:

自动生成的功能

  • AMIS前端界面:基于控制器和DTO的特性自动生成
    • 表格展示(支持头像、日期格式化、状态显示等)
    • 表单编辑(支持表单分组、树形选择、图片上传等)
    • 多条件搜索筛选(关键字、部门、状态、日期范围等)
    • 批量操作(批量删除等)
  • 统一的API响应格式:使用ApiResponse<T>统一响应
  • 分页查询:支持分页、排序、多条件筛选
  • 批量操作:支持批量删除、批量导入等操作
  • 异常处理:统一的异常处理和错误响应
  • 权限控制:支持基于特性的权限控制
  • 审计日志:自动记录创建、更新、删除操作
  • 多租户支持:自动进行数据隔离
  • 软删除支持:删除操作使用软删除,数据可恢复

标准CRUD操作

操作 HTTP方法 路径 说明
查询列表 GET /api/identity/Employees 支持多条件查询和关键字搜索
查询详情 GET /api/identity/Employees/{id} 根据ID获取单个职工
创建 POST /api/identity/Employees 创建新职工
更新 PUT /api/identity/Employees/{id} 更新职工信息
删除 DELETE /api/identity/Employees/{id} 删除单个职工(软删除)
批量删除 POST /api/identity/Employees/batch-delete 批量删除职工
根据部门查询 GET /api/identity/Employees/department/{departmentId} 根据部门获取职工列表
设置激活状态 PUT /api/identity/Employees/{id}/active 设置职工激活状态
转移部门 PUT /api/identity/Employees/{id}/transfer 转移职工到新部门
办理离职 PUT /api/identity/Employees/{id}/terminate 办理职工离职

业务验证示例

创建时验证

protected override async Task ValidateCreateDto(CreateEmployeeDto createDto)
{
    await base.ValidateCreateDto(createDto);

    // 验证工号唯一性
    bool isUnique = await IsEmployeeNoUniqueAsync(createDto.EmployeeNo);
    if (!isUnique)
    {
        throw new AppServiceException(400, $"工号 {createDto.EmployeeNo} 已存在,请使用其他工号");
    }

    // 验证部门是否存在
    if (createDto.DepartmentId.HasValue)
    {
        var departmentExists = await _departmentRepository.ExistsAsync(d => d.Id == createDto.DepartmentId.Value);
        if (!departmentExists)
        {
            throw new AppServiceException(400, "部门不存在");
        }
    }

    // 验证用户是否存在(如果指定了用户ID)
    if (createDto.UserId.HasValue)
    {
        var userExists = await _userRepository.ExistsAsync(u => u.Id == createDto.UserId.Value);
        if (!userExists)
        {
            throw new AppServiceException(400, "用户不存在");
        }
    }
}

更新时验证

protected override async Task ValidateUpdateDto(long id, UpdateEmployeeDto updateDto)
{
    await base.ValidateUpdateDto(id, updateDto);

    // 验证工号唯一性(排除当前记录)
    bool isUnique = await IsEmployeeNoUniqueAsync(updateDto.EmployeeNo, id);
    if (!isUnique)
    {
        throw new AppServiceException(400, $"工号 {updateDto.EmployeeNo} 已存在,请使用其他工号");
    }

    // 验证部门是否存在
    if (updateDto.DepartmentId.HasValue)
    {
        var departmentExists = await _departmentRepository.ExistsAsync(d => d.Id == updateDto.DepartmentId.Value);
        if (!departmentExists)
        {
            throw new AppServiceException(400, "部门不存在");
        }
    }
}

创建前处理

protected override async Task<Employee> OnCreating(CreateEmployeeDto createDto)
{
    var employee = await base.OnCreating(createDto);
    
    // 设置租户ID
    employee.TenantId = _currentUser.TenantId;
    
    // 生成ID(如果需要)
    if (employee.Id == 0)
    {
        employee.Id = await _idGenerator.GenerateIdAsync();
    }

    return employee;
}

扩展功能示例

添加权限控制

[HttpPost]
[DisplayName("创建职工")]
[Permission("identity_employees_create")]  // 添加权限控制
public async Task<ActionResult<ApiResponse<EmployeeDto>>> CreateEmployee(CreateEmployeeDto createDto)
{
    // ...
}

添加导航菜单

[DisplayName("职工管理")]
[Navigation(Icon = "fa-solid fa-user-tie", PlatformType = PlatformType.Tenant)]  // 添加到导航菜单
public class EmployeesController : ApiControllerBase
{
    // ...
}

自定义查询方法

/// <summary>
/// 获取在职职工列表
/// </summary>
public async Task<List<EmployeeDto>> GetActiveEmployeesAsync()
{
    var employees = await Repository.CreateQuery()
        .Where(e => e.IsActive && e.EmploymentStatus == EmploymentStatus.Active)
        .Include(e => e.Department)
        .Include(e => e.User)
        .ToListAsync();

    return Mapper.Map<List<EmployeeDto>>(employees);
}

使用PageAside特性实现侧边栏筛选

PageAside()特性用于将查询字段放置在页面侧边栏,特别适用于树形选择、分类筛选等场景。使用此特性后,该字段会从主查询表单中移除,仅在侧边栏显示。

特性说明

/// <summary>
/// 部门ID筛选
/// </summary>
[DisplayName("部门")]
[AmisInputTreeField(
    DataSource = "${ROOT_API}/api/identity/Departments/tree",
    Multiple = false,
    JoinValues = true,
    ExtractValue = false,
    ShowOutline = true,
    LabelField = "name",
    ValueField = "id",
    Required = false,
    Clearable = true,
    SubmitOnChange = true,  // 选择后自动提交查询
    HeightAuto = true,
    SelectFirst = false,
    InputOnly = true,
    ShowIcon = true
)]
[PageAside()]  // 标记为侧边栏字段
public long? DepartmentId { get; set; }

image
PageAside特性的主要属性

  • Target:表单提交目标,如果为空则自动设置为CRUD组件名称
  • SubmitOnInit:是否在初始化时提交,默认为false
  • WrapWithPanel:是否不使用面板包装,默认为false
  • AsideResizor:侧边栏宽度是否可调整,默认为true
  • AsideMinWidth:侧边栏最小宽度(像素),默认为0
  • AsideMaxWidth:侧边栏最大宽度(像素),默认为0
  • AsideSticky:侧边栏是否固定,默认为true
  • AsidePosition:侧边栏位置(Left/Right),默认为Left

使用场景

  1. 树形分类筛选:如部门树、分类树等,放在侧边栏作为导航筛选器
  2. 独立筛选器:需要独立展示的筛选条件,避免主表单过于拥挤
  3. 联动查询:侧边栏字段变化时自动触发主内容区域刷新

注意事项

  • 标记了PageAside()特性的字段会自动从主查询表单中排除
  • 建议配合SubmitOnChange = true使用,实现选择后自动查询
  • 侧边栏字段的查询条件会自动合并到主查询中

最佳实践

  1. 实体设计

    • 实现IFullAuditable接口获得完整的审计字段(创建、更新、删除)
    • 实现IMultiTenant接口支持多租户数据隔离
    • 实现IIsActive接口支持激活状态管理
    • 合理设计导航属性,使用Include避免N+1查询问题
    • 为唯一性字段创建复合唯一索引(租户ID + 业务字段)
  2. DTO分离

    • 为创建、更新、查询分别创建DTO
    • 使用DisplayName特性提供友好的字段名称
    • 使用AmisColumn特性控制前端表格列显示
    • 使用FormGroup特性将表单字段分组,提升用户体验
    • 使用AmisInputTreeField等特性自动生成合适的表单组件
  3. 服务层

    • 继承BaseCRUDIService获得CRUD和批量导入功能
    • 服务接口继承IScopedDependency接口自动注册
    • 重写ValidateCreateDtoValidateUpdateDto实现业务验证
    • 重写OnCreating方法设置租户ID和生成ID
    • 使用LinqKitPredicateBuilder构建动态查询条件
  4. 控制器

    • 保持简洁,主要调用服务层方法
    • 使用DisplayNameNavigation特性
    • 使用Operation特性配置操作按钮(删除确认对话框)
    • 提供额外的业务操作接口(如设置激活状态、转移部门等)
  5. 验证

    • 使用DataAnnotations进行基础数据验证
    • 重写服务层的验证方法实现业务验证(唯一性、关联存在性等)
    • 使用AppServiceException抛出业务异常
    • 在数据库层面创建唯一索引确保数据完整性
  6. 数据库设计

    • 为常用查询字段创建索引提升性能
    • 合理配置关联关系的级联删除策略
    • 使用复合唯一索引确保租户内业务字段唯一性
  7. 文档注释

    • 为所有公共成员添加XML文档注释
    • 使用<summary><param><returns>标签

相关文档

总结

通过CodeSpirit框架的BaseCRUDIService和标准开发模式,您可以快速开发出功能完整的CRUD接口。职工管理模块展示了:

  • ✅ 标准CRUD操作的实现
  • ✅ 关联关系管理(部门、用户账号)
  • ✅ 业务验证逻辑的编写(工号唯一性、部门存在性等)
  • ✅ 多条件查询的实现(关键字、部门、状态、日期范围等)
  • ✅ 表单分组展示的使用
  • ✅ 额外业务操作的实现(设置激活状态、转移部门、办理离职等)
  • ✅ AMIS特性的使用(表格列、表单字段、图片上传等)

框架会自动处理大部分样板代码,让您专注于业务逻辑的实现。

更多交流请关注“CodeSpirit-码灵”公众号进群!!!

祝您开发愉快!🚀

posted @ 2025-12-30 00:15  雪雁  阅读(0)  评论(0)    收藏  举报