EasyAdminBlazor 审批模块实战:从提交、审批到最终完成

Original 2026-10-01 673 views

审批是 2.3 新增的重要能力。这篇文章不讨论"要不要上工作流引擎",只讲一件事:用状态机 + 审批记录表,把固定 2~3 级审批做扎实,以及怎么在半小时内把它接进一个已经有了 AdminTable 的模块。


一、先划清边界

这个模块的定位在源码注释里写得很清楚:

轻量多级审批:状态机(状态存放在业务表)+ 审批记录表(流水 + 待办索引)。

能做的:

  • 固定串联的 1N 级审批(常见 23 级)
  • 审批人来源:部门负责人 / 角色 / 指定用户 / 单据字段
  • 或签(同一节点多人,任一人处理即通过)
  • 提交、同意、驳回、撤回、转交
  • 待办 / 已办 / 我的申请 / 全部
  • 轮次与时间线、变更重审、通过后锁定

不做(源码里也明说了): 会签、并行、条件分支、回退到任意节点。这些属于工作流引擎(如 Elsa)的范畴,硬套状态机只会得到一个越来越难维护的 switch。


二、状态与动作

1. 五个状态

public enum ApprovalStatus
{
    Draft = 0,      // 草稿(未提交审批)
    Pending = 1,    // 审批中
    Approved = 2,   // 已通过
    Rejected = 3,   // 已驳回
    Revoked = 4     // 已撤回
}

关键在于:"第几级"不放在状态里,而是单独一个 CurrentLevel 字段:

public interface IApprovalBill
{
    /// <summary>审批状态</summary>
    ApprovalStatus ApprovalStatus { get; set; }

    /// <summary>当前待审级次:0=未提交,1/2/3=第几级</summary>
    int CurrentLevel { get; set; }
}

这样状态只有 5 个(可枚举、可穷举测试),级次可以任意扩展。

2. 五个动作

public enum ApprovalAction
{
    Submit,    // 提交
    Approve,   // 同意
    Reject,    // 驳回
    Revoke,    // 撤回
    Transfer   // 转交
}

状态机是纯函数,不碰数据库,所以能穷举单测:

public static (ApprovalStatus Status, int Level) Next(ApprovalAction action, int currentLevel, int maxLevel)
{
    if (maxLevel < 1) throw new ArgumentOutOfRangeException(nameof(maxLevel), "审批流程至少需要一级");

    return action switch
    {
        ApprovalAction.Submit  => (ApprovalStatus.Pending, 1),
        ApprovalAction.Approve when currentLevel < maxLevel => (ApprovalStatus.Pending, currentLevel + 1),
        ApprovalAction.Approve => (ApprovalStatus.Approved, 0),
        ApprovalAction.Reject  => (ApprovalStatus.Rejected, 0),
        ApprovalAction.Revoke  => (ApprovalStatus.Revoked, 0),
        ApprovalAction.Transfer => (ApprovalStatus.Pending, currentLevel),
        _ => throw new InvalidOperationException($"未知的审批动作:{action}")
    };
}

把状态流转画出来:

草稿 ──提交──> 一级审批中 ──同意──> 二级审批中 ──同意──> 已通过
                  │                     │
                  ├──驳回───────────────┴──> 已驳回 ──修改后提交──> 一级(新一轮)
                  └──撤回(无人审批前)───> 已撤回 ──提交──> 一级(新一轮)

审批中 ──转交──> 级次不变,只换审批人

三、五步接入

第一步:安装并注册

dotnet add package EasyAdminBlazor.Approval
builder.AddEasyAdminBlazor(new EasyAdminBlazorOptions { ... })
       .AddEasyAdminBlazorApproval(o =>
       {
           o.Flows.Add(new ApprovalFlowConfig
           {
               BillType = typeof(Article).FullName!,
               Levels =
               [
                   new ApprovalLevelConfig { Level = 1, Name = "部门主管审批", Kind = ApproverKind.OrgLeader, Offset = 1 },
                   new ApprovalLevelConfig { Level = 2, Name = "管理员审批",   Kind = ApproverKind.Role, Value = "Administrator" }
               ]
           });
       });

注册做了什么(ServiceCollectionExtensions.cs):

注册项 说明
IApprovalFlowProvider → SysConfigApprovalFlowProvider 流程配置来源:代码注册 or 后台参数配置
IApproverResolver → DefaultApproverResolver 审批人解析
IApprovalUserProvider → AdminApprovalUserProvider 当前用户 / 权限 / 组织
IApprovalNotifier → AdminMessageApprovalNotifier 站内信 + SignalR 通知
IApprovalGateway → ApprovalService 替换核心包的 NullApprovalGateway
ApprovalInitializer(HostedService) 启动时建表、修复历史轮次

这张表里最重要的一行是最后一行:核心包里的 AdminTable 只依赖 IApprovalGateway 接口(未安装扩展时是空实现),所以审批是可选扩展,不装也不会影响其他功能。

流程也可以放到后台"参数配置"里,Code 为 APPROVAL_FLOW_{单据全名},值是流程 JSON——改了不用重启。

第二步:业务实体接入审批

把基类换掉即可,审批字段由基类提供:

// 需要软删除
public partial class Article : ApprovalEntityFull
{
    // 原有字段不动
}

// 不需要软删除
public partial class Order : ApprovalEntity { }

两个基类的唯一区别是继承链(EntityFull / EntityCreated),字段完全一致:

public abstract class ApprovalEntityFull : EntityFull, IApprovalBill
{
    [Column(Position = -8)]
    [DisplayName("审批状态")]
    public virtual ApprovalStatus ApprovalStatus { get; set; } = ApprovalStatus.Draft;

    [Column(Position = -7)]
    [DisplayName("审批级次")]
    public virtual int CurrentLevel { get; set; }

    /// <summary>列表展示用,不落库</summary>
    [Column(IsIgnore = true)]
    [DisplayName("审批状态")]
    public string ApprovalStatusText => ApprovalStatus switch
    {
        ApprovalStatus.Draft => "草稿",
        ApprovalStatus.Pending => CurrentLevel > 0 ? $"{CurrentLevel} 级审批中" : "审批中",
        ApprovalStatus.Approved => "已通过",
        ApprovalStatus.Rejected => "已驳回",
        ApprovalStatus.Revoked => "已撤回",
        _ => string.Empty
    };
}

ApprovalStatusText 是 [Column(IsIgnore = true)] 的展示属性——表格里直接绑它就能显示"2 级审批中"这种可读文案。

如果实体已经继承了别的基类,直接实现接口 + 手写两个字段也可以:

public class MyBill : EntityCreated, IApprovalBill
{
    [Column(Position = -8)] public ApprovalStatus ApprovalStatus { get; set; }
    [Column(Position = -7)] public int CurrentLevel { get; set; }
}

注意源码里的提醒:接口只提供契约,字段仍需由实体或基类声明,否则 ORM 不会建列。发起人和提交时间复用 IEntityCreated 的 CreatedUserId / CreatedTime,不需要额外字段。

第三步:页面接入

方式一:AdminTable 自动接管。

实体实现 IApprovalBill 后,AdminTable 会自动识别:

// 实体实现 IApprovalBill 时自动接管审批(未安装 Approval 扩展时空实现直接跳过)
_autoApproval = typeof(IApprovalBill).IsAssignableFrom(typeof(TItem));

自动做的事:

  • 保存后按配置决定是否自动提交审批;
  • 审批中的单据禁止编辑、删除;
  • 如果你在列里绑了 ApprovalStatus / 状态文本,就能显示审批状态。

方式二:审批面板组件。

更常见也更直观的做法是在编辑模板里加一个"审批"选项卡:

@inject CommonLocalizer CommonLocalizer
<Tab IsCard>
    <TabItem Text="@CommonLocalizer["随笔"]">
        <!-- 业务字段 -->
    </TabItem>
    <TabItem Text="@CommonLocalizer["审批"]">
        <ApprovalActions TItem="Article" Bill="Model" OnChanged="OnApprovalChanged" />
    </TabItem>
</Tab>

@code {
    [Parameter][NotNull] public Article? Model { get; set; }
    [Parameter] public EventCallback OnApprovalChanged { get; set; }
}

ApprovalActions 组件内部(Components/ApprovalActions.razor)会:

  1. 显示当前审批状态徽章;
  2. 按 CanSubmitAsync / CanCurrentUserApproveAsync 决定显示哪些按钮;
  3. 提供审批意见输入框;
  4. 显示审批轨迹(ShowHistory,默认 true);
  5. 操作成功后把最新状态同步回传入的单据对象,再触发 OnChanged 让宿主刷新列表。

最后那一步有个容易踩的坑,源码里专门写了注释:

/// <summary>
/// 把最新的审批状态同步回传进来的单据对象。
/// 服务改的是数据库,而列表/表单持有的是同一个实体实例,不同步的话界面上会一直显示旧状态。
/// </summary>
private async Task SyncBillStateAsync()
{
    if (Bill is null) return;

    var view = await ApprovalGateway.GetBillViewAsync(BillType, Bill.Id);
    if (view?.Status is null) return;

    Bill.ApprovalStatus = view.Status.Value;
    Bill.CurrentLevel = view.CurrentLevel;
}

宿主页面再加一个状态列,并让 OnChanged 刷新表格:

<TableColumn @bind-Field="context.ApprovalStatus" Filterable="true">
    <Template Context="v">@v.Row.ApprovalStatusText</Template>
</TableColumn>
...
<EditTemplate>
    <ArticleEditTemplate Model="context" OnApprovalChanged="ReloadAsync" />
</EditTemplate>

@code {
    private AdminTable<Article, long> _table = default!;

    private async Task ReloadAsync()
    {
        if (_table is not null) await _table.Reload();
    }
}

第四步:配好审批人

四种审批人来源:

public enum ApproverKind
{
    [Display(Name = "部门负责人")] OrgLeader = 0,   // 按 SysOrg.ResponsibleUserId 逐级向上查找
    [Display(Name = "角色")]       Role = 1,        // 该角色下全部有效用户,或签
    [Display(Name = "指定用户")]   User = 2,
    [Display(Name = "单据字段")]   FormField = 3    // 字段值作为审批人 Id
}

解析规则(DefaultApproverResolver):

来源 配置 行为
部门负责人 Offset = 1 从发起人所在组织开始向上找第 N 个"负责人";中间层缺负责人自动继续向上
角色 Value = "财务" 取该角色下所有 IsEnabled != false 的用户,或签
指定用户 Value = "10086" 用户必须存在且未停用,否则视为无人可审
单据字段 Field = "AuditorId" 支持 long / long? / long[] / 可解析为 long 的字符串

两个容易被忽略的细节:

(1)默认禁止自审。

/// <summary>是否允许"自己审自己"(提交人成为自己单据的审批人),默认 false。</summary>
public bool AllowSelfApproval { get; set; }

默认不允许时:部门负责人这一级会继续向上找(单人部门很常见);角色 / 单据字段会把提交人从名单里排除。排除后没人可审,提交时就会提示"未找到「XX」的审批人,请检查审批流程配置"。

要允许自审(比如单人部门负责人就是唯一员工),显式打开:

.AddEasyAdminBlazorApproval(o => { o.AllowSelfApproval = true; ... })

(2)审批人在提交时一次性解析并落库。

// 审批人在提交时一次性解析并落库(固定 2~3 级流程),
// 这样"我的待办"只需查审批记录一张表,不用遍历各业务表。

这是设计上的关键取舍:解析结果存进 sys_approval_record,待办查询就变成"查一张表 + 两个索引",不需要为了显示待办去 union 所有业务表。

第五步:跑通流程

把上面的配好之后:

  1. 用户新增一条 Article → 状态是 Draft(草稿,保存与提交分离);
  2. 在"审批"选项卡点提交审批 → 状态变 Pending,CurrentLevel = 1;
  3. 一级审批人收到站内信"待您审批:xxx",点进去到审批中心;
  4. 审批人点同意 → 若还有下一级则 CurrentLevel + 1,否则 Approved;
  5. 期间发起人可以在无人审批前撤回;审批人可以转交给别人;
  6. 被驳回后修改再提交,会进入新一轮(Round + 1),旧轮次记录完整保留。

四、sys_approval_record:一张表干三件事

审批记录表的表结构注释写得非常清楚:

一张表同时承担三个角色:
1) 审批流水:按单据查询即为完整审批轨迹(Timeline 展示);
2) 待办索引:IsCurrent and Status=Pending and ApproverUserId=我 即"我的待办",无需跨业务表查询;
3) 通知目标:提交/过级时按本表审批人推送站内信。

对应两个索引:

[Index("idx_approval_record_bill", "BillType,BillId", false)]
[Index("idx_approval_record_todo", "ApproverUserId,IsCurrent", false)]

几个语义要点:

字段 含义
Level 0 = 发起记录,1..N = 审批节点
Round 轮次;驳回/撤回后重新发起会 +1
IsCurrent 是否当前待处理节点(待办查询的过滤条件)
Status 节点结果:Pending / Submitted / Approved / Rejected / Revoked / Transferred / Skipped
ApproverUserId "该节点应由谁处理"
OperatorUserId "实际是谁操作的"(转交、代审时两者不同)
BeforeStatus / AfterStatus 审计用的状态变更前后快照
InstanceKey {BillType}#{BillId}#{Round},把实例、节点、历史关联起来

Skipped 状态是"或签"场景的产物:A 先同意了,同节点其他待处理记录会被置为 Skipped,避免节点被完成两次。


五、待办、通知与审批中心

1. 审批中心页面

框架内置了 /Admin/ApprovalTodo,支持四种视角:

public enum ApprovalListScope
{
    Todo = 0,       // 待我处理
    Done = 1,       // 我处理过的(已办)
    Submitted = 2,  // 我发起的(我的申请)
    All = 3         // 全部(仅管理员)
}

菜单和按钮由 ApprovalMenuProvisioner 自动补齐,且是幂等的:

1) 没有审批菜单就创建(一级菜单「审批中心」);
2) 老结构(审批中心分组 + 我的待办子菜单)升级成单一一级菜单;
3) 补齐 approve/reject/revoke 三个按钮权限。

这三步是旧项目能平滑升级的关键:框架的 SeedData 只在菜单表为空时插入,老项目装了扩展后菜单表非空,新菜单不会自动出现,所以由扩展在启动时补齐。

按钮权限同样要勾给角色——非管理员用户如果没有 approve / reject / revoke 权限,审批按钮不会显示,服务端也会拒绝。管理员自动放行。

2. 通知

默认通知走站内信(含 SignalR 实时推送):

public interface IApprovalNotifier
{
    /// <summary>推送待办通知给当前节点审批人</summary>
    Task NotifyPendingAsync(IReadOnlyCollection<SysApprovalRecord> records);

    /// <summary>把审批结果通知发起人</summary>
    Task NotifyResultAsync(SysApprovalRecord record, ApprovalStatus status);
}

待办通知的收件人计算有个值得学习的细节:

/// <summary>
/// 计算待办通知的收件人:只认审批节点——级次大于 0、状态为待处理、且有审批人。
/// 发起记录(Level=0)里同样存了 ApproverUserId,但那个值是提交人,
/// 不能被当成审批人,否则提交人就会收到本该发给审批人的待办通知。
/// </summary>
public static long[] ResolveRecipients(IEnumerable<SysApprovalRecord> records)
    => records
        .Where(x => x.Level > 0
                    && x.Status == ApprovalRecordStatus.Pending
                    && x.ApproverUserId is > 0)
        .Select(x => x.ApproverUserId!.Value)
        .Distinct()
        .ToArray();

通知里的跳转地址统一是 /Admin/ApprovalTodo,点击直接进审批中心。

还有一条原则:通知失败不影响审批流程本身(try/catch + 日志告警),并且通知只在事务提交之后发送——第 18 篇会展开。


六、策略配置速查

ApprovalOptions 里的全局开关(流程配置优先):

配置 默认 含义
Enabled true 是否启用审批扩展
AutoCreateMenu true 菜单缺失时自动创建审批中心
AllowSelfApproval false 是否允许自己审自己
AutoSubmitOnSave false 保存后是否自动提交(默认保存与提交分开)
ResubmitOnEdit true 已通过的单据被修改后是否重新走审批
AllowEditAfterApproved false 已通过的单据是否还允许修改(默认通过后锁定)
RequireCommentOnReject true 驳回是否必须填意见
RequireCommentOnApprove false 同意是否必须填意见
AllowRevoke true 发起人能否在无人审批前撤回
EnableNotification true 是否推送站内信通知

流程级可覆盖的:ResubmitOnEdit、AutoSubmitOnSave、AllowEditAfterApproved、ResubmitFields。

其中"变更重审"的判定方式值得单独提一句:提交时把 ResubmitFields 里各字段的值按字段名排序拼成文本做 SHA256,存进该轮发起记录的 ResubmitHash;再次保存时重新算一遍比对,一致就跳过审批。只存指纹不存原值,不会把业务数据抄一份进审批记录。


七、常见问题

现象 原因 处理
提交时提示"未找到「XX」的审批人" 审批人解析结果为空 检查组织负责人是否维护、角色下是否有有效用户;或打开 AllowSelfApproval
非管理员看不到审批按钮 角色没勾 approve / reject / revoke 审批中心菜单下勾按钮权限,或确认 ApprovalMenuProvisioner 已补齐
保存后没有进入审批 默认保存与提交分离 打开 AutoSubmitOnSave,或让用户点"提交审批"
已通过的单据改不了 默认通过后锁定 按需打开 AllowEditAfterApproved
改了单据却没有重审 关键字段没变(ResubmitFields 指纹一致) 确认改的是关键字段,或调整 ResubmitFields
待办列表看不到单据 扩展未安装 / 菜单未创建 确认 AddEasyAdminBlazorApproval 已注册且 AutoCreateMenu = true
提交人收到了待办通知 早期版本的收件人计算问题 2.3 已修复为"只认审批节点(Level > 0)"

八、小结

EasyAdminBlazor 2.3 的审批模块,核心就三样东西:

  1. 状态机(ApprovalStateMachine)解决"怎么流转",纯函数、可穷举测试;
  2. 审批记录表(sys_approval_record)解决"轨迹、待办、通知"三件事,一张表 + 两个索引;
  3. 流程配置(ApprovalFlowConfig)解决"谁能审",四种来源 + 或签 + 禁止自审。

接入成本很低:装包、注册流程、换个实体基类、编辑模板加一个"审批"选项卡。剩下的复杂部分(并发、事务、通知时序)框架已经处理好了——那正是后面两篇要讲的内容。


如果你的 .NET 10 + Blazor 后台需要多级审批,又不想引入完整工作流引擎,EasyAdminBlazor 的轻量审批可以先用起来:状态存在业务表上,审批记录独立成表,扩展和降级都容易。