轻量多级审批

固定两级、三级审批(主管 → 经理 → 总经理、申请人 → 部门 → 财务)这类需求,用 状态机 + 审批记录表 就能覆盖,不需要引入工作流引擎。

设计要点

关注点 做法
审批状态存哪 直接存在业务表上(ApprovalStatus + CurrentLevel),不建流程实例表
流转历史存哪 一张 sys_approval_record 表,同时充当审批流水、待办索引、通知目标
谁能审 由流程配置决定:部门负责人 / 角色 / 指定用户 / 单据字段
并发怎么防 状态 + 级次的条件更新,重复点击和多人同时审批只会成功一次
状态机放哪 代码里的纯函数(ApprovalStateMachine),不落库,可穷举单测

不适用的场景:会签、并行、条件分支、回退到任意节点。遇到这些请改用工作流引擎(如 Elsa)。

审批级数没有上限:流程配置里写几个节点就走几级(1 级到 N 级都行,常见是 2~3 级)。 级数过多时真正需要的是"条件路由"而不是固定串联,那属于工作流引擎的范畴。

单据状态与流转规则

状态机

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

审批中 ──转交──▶ 级次不变,只换审批人
  • 状态只有 5 个:草稿 / 审批中 / 已通过 / 已驳回 / 已撤回;"第几级"单独放在 CurrentLevel
  • 动作只有 5 个:提交 / 同意 / 驳回 / 撤回 / 转交;规则是纯函数 ApprovalStateMachine,不落库,可穷举单测
  • 并发保护:每个动作都带"状态 + 级次"的条件更新,重复点击或多人同时审批只会成功一次,其余提示"该单据已被其他人处理,请刷新后重试"

各状态下单据能不能改

当前状态 保存修改 删除 说明
草稿 可以,保存后停在草稿(默认不自动提交) 可以 点审批选项卡里的"提交审批"才进流程;想恢复"保存即提交"见下面的开关
审批中 不可以,提示"单据正在审批中,不能修改或删除" 不可以 例外:当前节点配了 "allowEdit": true,且操作人正是该节点审批人
已通过 默认不可以(通过后锁定);开启 AllowEditAfterApproved 后可改,改完按重审策略处理 可以 默认策略是"通过后锁定"
已驳回 / 已撤回 可以,保存后重新从一级提交 可以 正常的"改完重提"路径

变更重审策略

三个配置项,都可以全局设默认、按单据类型单独覆盖(流程配置优先):

配置 作用范围 默认 含义
AllowEditAfterApproved 全局 / 流程 false 已通过的单据是否还允许修改;false = 通过后锁定
ResubmitOnEdit 全局 / 流程 true 允许修改的前提下,改完是否重新走审批
ResubmitFields 仅流程 空 触发重审的关键字段;为空表示任意字段变化都重审
.AddEasyAdminBlazorApproval(o =>
{
    o.AllowEditAfterApproved = false;   // 全局默认:通过后锁定(业界最常见)

    o.Flows.Add(new ApprovalFlowConfig
    {
        BillType = typeof(Article).FullName!,
        AllowEditAfterApproved = true,                                      // 这类单据通过后还能改
        ResubmitOnEdit = true,                                             // 改了就重审
        ResubmitFields = [nameof(Article.Title), nameof(Article.Content)],  // 但只有标题/正文算关键字段
        Levels = [ /* 见前面的流程配置 */ ]
    });
});

四种常见做法对应的配置:

想要的策略 配置
通过后锁定(财务、合同、库存等"过账即不可逆"的单据) AllowEditAfterApproved = false(默认)
改后只对关键字段重审(表单字段多的业务单据) AllowEditAfterApproved = true + ResubmitFields = [关键字段]
改后一律重审(字段少、语义单一) AllowEditAfterApproved = true + ResubmitFields 留空
改完直接生效(不重审) AllowEditAfterApproved = true + ResubmitOnEdit = false

关键字段怎么判断:提交时把 ResubmitFields 里各字段的取值按字段名排序拼成文本做 SHA256,存进该轮的发起记录(sys_approval_record.ResubmitHash);再次保存时重新算一遍比对,一致就跳过审批。

两点说明:

  • 只存指纹不存原值,不会把业务数据抄一份进审批记录
  • 改了 ResubmitFields 配置后,旧指纹必然对不上,会按"关键字段变了"处理(偏安全的方向)

另外还有一类需求是按金额/风险分级重审(小额免审、超阈值才走全套),这属于流程路由,不在本扩展范围内。

轮次

驳回/撤回后重新提交、已通过后变更重审,都会进入新的轮次:

  • 时间线按「轮次 → 级次」排序,第二轮及以后节点名带"第 2 轮 ·"前缀
  • 旧轮次的审批记录完整保留,不会覆盖(审计需要)

保存与提交分离

默认就是分开的:保存只保存,单据停在草稿;在业务页面的"审批"选项卡里点提交审批才进入流程。这样更贴近 OA 的用法(先填、再核、后提交)。

想改成"保存即提交":

.AddEasyAdminBlazorApproval(o =>
{
    o.AutoSubmitOnSave = true;                 // 全局默认:保存后自动提交审批

    o.Flows.Add(new ApprovalFlowConfig
    {
        BillType = typeof(Article).FullName!,
        AutoSubmitOnSave = true,               // 也可以只对某类单据开启(流程配置优先)
        Levels = [ /* ... */ ]
    });
});

关掉自动提交后,提交动作由用户触发;IApprovalService.CanSubmitAsync(单据类型, 当前状态) 可以判断当前状态是否允许提交(界面据此显隐按钮),后端仍然会在 SubmitAsync 里再校验一次。

1. 安装注册

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 = "财务" }
               ]
           });
       });

扩展会自动建表(sys_approval_record),无需手写迁移。

2. 业务实体接入

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

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

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

两种基类的区别只是继承链(EntityFull / EntityCreated)。如果实体已经继承了别的基类,直接实现 IApprovalBill 接口并手写两个字段也可以:

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

接口负责契约(组件与引擎按它识别单据),字段仍需由实体或基类声明——接口成员不会生成数据库列。

提交人与提交时间复用 EntityCreated 的 CreatedUserId / CreatedTime,无需额外字段。

3. 页面接入

方式一:AdminTable 自动接管(推荐)

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

  • 保存成功后提交审批(未配置流程的单据类型不受影响)
  • 审批中的单据禁止编辑、删除
  • 列表里加一列审批状态(ApprovalStatusText)

方式二:审批面板组件

@using EasyAdminBlazor.Approval.Components

<ApprovalActions TItem="Article" Bill="Model" OnChanged="Reload" />

组件会根据当前状态和当前登录用户显示"同意 / 驳回 / 撤回"按钮,并展示完整审批轨迹。

方式三:自己写页面

注入 IApprovalService,调用 SubmitAsync / ApproveAsync / RejectAsync / RevokeAsync / TransferAsync,返回 ApprovalSubmitResult(Submitted=false 时 Message 是原因)。

4. 审批人来源

Kind 说明 Value / Field
OrgLeader 部门负责人,按 SysOrg.ResponsibleUserId 从申请人所在部门向上找 Offset:1=本部门,2=上级部门
Role 角色下全部有效用户(或签,谁先审谁生效) Value = 角色名
User 指定用户 Value = 用户 Id
FormField 取单据字段的值作为审批人 Field = 字段名

解析出的用户必须存在且未被停用,否则提交时直接提示"未找到「XX」的审批人",避免产生无人可办的幽灵待办。

审批人在提交时一次性解析并落库,所以中途换人请用"转交",而不是改配置。

5. 审批中心

框架自带一级菜单 审批中心(/Admin/ApprovalTodo,页面在核心项目 EasyAdminBlazor/Pages/ApprovalTodo.razor),装好扩展、下发菜单权限即可用,不需要自己写页面。

一个菜单 + 四个视图(不再拆多个子菜单):

视图 内容
我的待办 IsCurrent 且 待处理 且 审批人=我;点同意/驳回后立刻从这里消失
已办 我实际处理过的节点(同意/驳回/转交),用于回查"我审过什么、当时写了什么意见"
我的申请 我发起过的每一轮,用于回答"我的单子卡在谁那里"。每行代表一轮:节点列显示该轮当前待办节点(审批中)或最后处理的节点(已结束,如"撤回"),状态列显示本轮结果(待处理 / 已通过 / 已驳回 / 已撤回),审批人和处理时间对应这个节点
全部 所有审批记录,仅管理员可见

每个视图都支持按单据标题搜索、按节点结果(待处理 / 已同意 / 已驳回 / 已转交 / 已跳过…)筛选,分页。

点任意一行,右侧显示单据内容(按实体字段与 DisplayName 自动渲染,长文本截断)、当前审批状态和完整审批轨迹(按轮次分组)。在"我的待办"里可以直接填意见并同意/驳回,审完列表自动刷新;其他视图是只读的。

在"我的申请"里选中自己发起的、还没人审过的单据,右侧会出现撤回按钮(撤回后可以改完重新提交,进入新一轮)。

菜单会自动就绪,旧项目也一样。 全新库由框架种子创建;旧项目的菜单表非空(种子不会再插入),装扩展后由扩展在启动时自动补建——不需要手工加菜单。不想要可关闭:ApprovalOptions.AutoCreateMenu = false。 早期版本是"审批中心(分组) + 我的待办(子菜单)",现已合并为单一一级菜单;升级时启动会自动转换,并且保留原子菜单的 Id,角色授权不会丢。 自动补建的菜单默认只对管理员可见,其他角色请到"角色管理"里勾选。

待办数据本身是一条单表查询,也可以在自己的页面里用:

var todos = await approvalService.GetTodoListAsync();   // 我的待办(实体)
var count = await approvalService.GetTodoCountAsync();

// 按单据类型名操作,不必为每种单据写泛型调用
await approvalService.ApproveByBillTypeAsync(record.BillType, record.BillId, "同意");
await approvalService.RejectByBillTypeAsync(record.BillType, record.BillId, "材料不全");

按钮权限

审批中心的"同意 / 驳回 / 撤回"走框架的按钮级权限(SysMenu 按钮 Path:approve / reject / revoke)。菜单种子已内置这三个按钮:给角色分配审批中心菜单时,把对应按钮勾上即可;管理员自动放行。

  • 界面层:没有按钮权限就不显示对应按钮
  • 服务层:ApprovalService 用同一套权限二次校验,绕过界面直接调接口也会被拒(返回"没有权限执行该操作")
  • 业务页面里的 <ApprovalActions> 面板同样受这套权限约束

业务列表里的"审批"选项卡是给另一种场景用的——当前节点配了 allowEdit 时,审批人需要先改单再通过(比如补一个金额、改一个分类)。

6. 其他

  • 通知:提交和过级时通过 AdminMessageService 发站内信并走 SignalR 实时推送,可用 ApprovalOptions.EnableNotification = false 关闭。
  • 改配置不重启:见下一节,后台"参数配置"里改流程,优先级高于代码里的默认流程。
  • 审批中允许编辑:把节点的 AllowEdit 设为 true,仅该节点审批人可以改单。
  • 变更重审:见前面的「变更重审策略」,三档可配,默认"通过后锁定"。
  • 驳回/同意必须填意见:RequireCommentOnReject(默认 true)、RequireCommentOnApprove(默认 false)。
  • 保存与提交:默认分开(保存后停在草稿,点"提交审批"才进流程);ApprovalOptions.AutoSubmitOnSave = true 可恢复"保存即提交",也可按单据类型单独开启。
  • 多语言:审批页、审批面板、状态文案与所有提示信息都走语言包,中英文已内置在核心的 EasyAdminBlazor/Locales/zh.json、en.json 的 EasyAdminBlazor.Common 块里。新增语言时把这一块整体翻译过去即可(见多语言)。 业务实体自己的字段名(如列表里的"审批状态"列)仍按框架惯例放在你项目的 Locales/{culture}.json 里,按 实体全名 → 属性名 配置。

7. 在后台配置流程

不用改代码,直接在后台 系统管理 → 参数配置 里新增一条配置:

字段 值
名称 随笔审批流程(随意填,仅用于辨识)
唯一码 APPROVAL_FLOW_Article
值 下面的 JSON
{
  "enabled": true,
  "allowEditAfterApproved": true,
  "resubmitOnEdit": true,
  "resubmitFields": [ "Title", "Content" ],
  "levels": [
    { "level": 1, "name": "部门主管审批", "kind": "OrgLeader", "offset": 1 },
    { "level": 2, "name": "财务复核", "kind": "Role", "value": "财务" },
    { "level": 3, "name": "总经理审批", "kind": "User", "value": "838392596680773" }
  ]
}

字段含义:

字段 说明
enabled 是否启用该单据类型的审批
allowEditAfterApproved 已通过的单据是否还允许修改;省略则用全局默认(false=通过后锁定)
autoSubmitOnSave 保存后是否自动提交审批;省略则用全局默认(false=保存与提交分开)
resubmitOnEdit 允许修改时,改完是否重新走审批;省略则用全局默认(true)
resubmitFields 触发重审的关键字段(实体属性名);省略或留空 = 任意字段变化都重审
level 级次,从 1 开始;省略时按数组顺序自动编号
name 节点名称,会显示在待办列表和审批轨迹里
kind OrgLeader / Role / User / FormField
value Role 填角色名,User 填用户 Id
offset 仅 OrgLeader:1=本部门负责人,2=上级部门负责人
field 仅 FormField:取哪个字段的值当审批人
allowEdit 该节点审批人能否顺手改单据,默认 false

唯一码要用短类名(APPROVAL_FLOW_Article,不是 APPROVAL_FLOW_EasyAdminBlazor.Test.Blog.Article)。 系统参数的唯一码字段只有 30 个字符,实体全名拼进去会超长(MySQL 下会被截断,配置就读不到了)。 查找顺序是「全名配置优先,其次短类名配置」,短类名同名时以先匹配到的为准。

保存后立即生效,无需重启。

8. 常见问题

提交时报"未找到「XX」的审批人" 该节点的审批人解析结果为空。检查组织是否维护了负责人、角色下是否有在职用户、指定用户是否存在。

单据改不了了 审批中的单据默认禁止编辑。要么先撤回/驳回,要么把当前节点配置为 AllowEdit = true。

某个模块不想走审批 不配置该单据类型的流程,或把 ApprovalFlowConfig.Enabled 设为 false。

审批人离职了怎么办 由当前节点审批人或管理员使用"转交"功能把待办转给其他人。

能看到审批记录但点不了同意/驳回 当前登录用户不是该节点审批人,也不是管理员。