轻量多级审批
固定两级、三级审批(主管 → 经理 → 总经理、申请人 → 部门 → 财务)这类需求,用 状态机 + 审批记录表 就能覆盖,不需要引入工作流引擎。
设计要点
| 关注点 | 做法 |
|---|---|
| 审批状态存哪 | 直接存在业务表上(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。
审批人离职了怎么办 由当前节点审批人或管理员使用"转交"功能把待办转给其他人。
能看到审批记录但点不了同意/驳回 当前登录用户不是该节点审批人,也不是管理员。