AdminTable 源码解析:EasyAdminBlazor 如何实现通用 CRUD?

本文对应版本:EasyAdminBlazor 2.3.0+

源码位置:

  • EasyAdminBlazor/Components/AdminTable.razor
  • EasyAdminBlazor/Infrastructure/FreeSql/FreeSqlExtensions.cs
  • 示例页面:EasyAdminBlazor.Test/Components/Admin/Product.razor

已发布过的《几十行代码搞定 CRUD》讲的是"怎么用",这篇讲的是"它到底替你做了什么"。读完你应该能在出问题时判断:是查询条件的问题、权限的问题,还是 ORM 映射的问题。


一、先认清 AdminTable 的身份

AdminTable 直接继承 BootstrapBlazor 的 Table<TItem>

razor 复制代码
@namespace BootstrapBlazor.Components
@implements IDisposable
@inherits Table<TItem>
@attribute [CascadingTypeParameter(nameof(TItem))]
@typeparam TItem where TItem : class, IEntity<TKey>, new()
@typeparam TKey where TKey : IEquatable<TKey>
@inject IAggregateRootRepository<TItem> _repo
@inject ITableExport TableExport
@inject NavigationManager NavigationManager
@inject EasyAdminBlazor.PrintService PrintService
@inject EasyAdminBlazor.IApprovalGateway ApprovalGateway

这段声明里有三个关键信息:

  1. 泛型约束要求实体必须实现 IEntity<TKey>。框架里的 Entity / EntityCreated / EntityFull 都已经满足,正常实体不用额外处理。
  2. 自动注入 IAggregateRootRepository<TItem>GetSelect() 就是把它暴露出来:
csharp 复制代码
public ISelect<TItem> GetSelect()
{
    return _repo.Select;
}
  1. 表格渲染直接走 BootstrapBlazor:@{ base.BuildRenderTree(__builder); }AdminTable 只负责把数据、权限、导入导出这些"后台语义"接上,UI 能力仍然复用 BootstrapBlazor。

也就是说,"几十行代码搞定 CRUD"不是魔法,而是实体 + 仓储 + 表格 + 约定四件事被组装好了。


二、一次查询是怎么跑起来的

1. 组件初始化时接管回调

OnParametersSetAsync 是整个组件的装配点:

csharp 复制代码
protected override async Task OnParametersSetAsync()
{
    // 实体实现 IDataPermission + IEntityCreated 时自动启用数据权限
    _autoDataPermission = typeof(IDataPermission).IsAssignableFrom(typeof(TItem))
        && typeof(IEntityCreated).IsAssignableFrom(typeof(TItem));

    // 实体实现 IApprovalBill 时自动接管审批
    _autoApproval = typeof(IApprovalBill).IsAssignableFrom(typeof(TItem));

    // 记录页面显式传入的委托(页面自定义回调需要额外包一层权限校验)
    var customSaveAsync = _writePermissionWrapped ? null : OnSaveAsync;
    var customDeleteAsync = _writePermissionWrapped ? null : OnDeleteAsync;

    // ⚠️ 必须在任何 await 之前赋值,否则首次渲染会以 null 回调触发查询
    if (OnQueryAsync == null && Items == null)
    {
        OnQueryAsync = OnQueryDataAsync;
        _querying = true;
    }

    this.GetIgnoredPropertyNames();

    if (OnSaveAsync == null)
        OnSaveAsync = _defaultSaveAsync ??= OnSaveDataAsync;
    else if (customSaveAsync != null && !ReferenceEquals(customSaveAsync, _defaultSaveAsync))
    {
        var originalSave = customSaveAsync;
        OnSaveAsync = (item, changedType) => SaveWithPermissionAsync(originalSave, item, changedType);
        _writePermissionWrapped = true;
    }
    // 删除同理
}

源码里那行注释不是摆设。Blazor 会在第一个 await 处触发首次渲染,如果此时 OnQueryAsync 还是 null,首屏就会出现"查不到数据"的假象。这是 AdminTable 里最容易踩、也最难从现象看出来的一类坑。

2. 默认查询:数据权限 + 动态筛选 + 排序 + 分页

真正的查询在 OnQueryDataAsync

csharp 复制代码
private async Task<QueryData<TItem>> OnQueryDataAsync(QueryPageOptions options)
{
    _querying = true;
    await InvokeAsync(StateHasChanged);
    try
    {
        var select = GetSelect();
        if (OnBeforeQuery.HasDelegate)
        {
            await OnBeforeQuery.InvokeAsync(new AdminQueryEventArgs<TItem>(select, options));
        }

        return await select.ApplyDataPermission(admin, UseDataPermission || _autoDataPermission)
                           .GetPagedAsync<TItem, TKey>(options, _IgnoreSearchColumns.ToArray());
    }
    finally
    {
        _querying = false;
        await InvokeAsync(StateHasChanged);
    }
}

调用链可以画成:

text 复制代码
AdminTable.OnQueryDataAsync
   → GetSelect()          FreeSql ISelect(_repo.Select)
   → OnBeforeQuery        页面自定义条件(Include / Join / Where)
   → ApplyDataPermission  数据权限过滤(第 09 篇详解)
   → GetPagedAsync        动态筛选 + 排序 + Count + 分页

3. GetPagedAsync 做了什么

GetPagedAsyncFreeSqlExtensions.cs,是 BootstrapBlazor 的 QueryPageOptions 和 FreeSql 之间的桥:

csharp 复制代码
public static async Task<QueryData<T>> GetPagedAsync<T, TKey>(
    this ISelect<T> select,
    QueryPageOptions options,
    params string[] ignoreColumns) where T : class, IEntity<TKey>
{
    // 把 QueryPageOptions 转成 FreeSql 的 DynamicFilterInfo
    var dynamicFilter = options.ToDynamicFilter(ignoreColumns);

    // Flags Enum 的过滤要换成位运算 SQL
    dynamicFilter = ProcessFlagsFilters(select, typeof(T), dynamicFilter);

    var query = select
       .WhereDynamicFilter(dynamicFilter)
       .ApplyOrder<T, TKey>(options)
       .Count(out var count);

    var items = options.IsPage
        ? await query.Page(options.PageIndex, options.PageItems).ToListAsync()
        : await query.ToListAsync();

    return new QueryData<T>()
    {
        Items = items,
        TotalCount = Convert.ToInt32(count),
        IsFiltered = true,
        IsSearch = true
    };
}

几个细节:

  • 搜索、高级搜索、自定义搜索、表头过滤统一转成 DynamicFilterInfoToDynamicFilterSearches(模糊搜索)用 Or 组合,把 CustomerSearches / AdvanceSearches / Filters 追加为条件,最终交给 FreeSql 的 WhereDynamicFilter
  • 排序交给 ApplyOrder:优先用表格传来的 SortListSortName,没有排序配置时保留 select 上原有的 OrderBy,不覆盖页面在 OnBeforeQuery 里写的默认排序。
  • ignoreColumns 不是可有可无的。像导航集合(比如用户的 Roles)不能被当成数据库列过滤。页面通过 IgnoreSearchColumns 声明要忽略的属性,AdminTable 在第一次渲染前就把属性名提取好。

4. 一个容易忽略的行为:Flags 枚举

如果实体里有 [Flags] 枚举字段(比如文章类型"原创 | 转载"),BootstrapBlazor 传过来的筛选值默认会走等值比较,永远筛不出"包含某一位"的数据。

AdminTable 的处理方式是在 ProcessFlagsFilters 里把这类节点从 DynamicFilterInfo 树上摘掉,改写成位运算 SQL:

csharp 复制代码
// 使用位运算判断是否包含该位
if (val > 0) select.Where($"(a.{columnName} & {val}) != 0");
return null; // 从树中移除该节点,避免 WhereDynamicFilter 重复处理

所以筛选"原创"能筛出"原创 + 转载"的记录。这段逻辑只对标记了 [Flags] 的枚举生效。


三、新增和编辑:不只是 Insert 和 Update

默认保存在 OnSaveDataAsync。顺序是这样的:

csharp 复制代码
private async Task<bool> OnSaveDataAsync(TItem item, ItemChangedType changedType)
{
    // 1) 服务端写操作校验:与 UI 按钮显隐使用同一权限判定,管理员自动放行
    var action = changedType == ItemChangedType.Add ? "add" : "edit";
    if (!await admin.AuthButton(action))
    {
        await MessageService.Error(string.Format(CommonLocalizer["没有权限执行该操作"]));
        return false;
    }

    // 2) 数据权限:更新时必须校验该记录是否属于当前用户可操作范围
    if (changedType == ItemChangedType.Update)
    {
        var authorized = await FilterAuthorizedAsync([item], CommonLocalizer["没有权限修改该数据"]);
        if (authorized.Count == 0) return false;
    }

    // 3) 页面回调
    if (OnBeforeSaveAsync.HasDelegate)
    {
        var args = new AdminSaveEventArgs<TItem> { Item = item, ChangedType = changedType };
        await OnBeforeSaveAsync.InvokeAsync(args);
        if (args.Cancel) return false;
    }

    // 4) 审批中的单据不允许修改
    if (_autoApproval)
    {
        var check = await ApprovalGateway.CheckModifyAsync(item);
        if (!check.Allowed)
        {
            await MessageService.Error(check.Message ?? CommonLocalizer["单据正在审批中,不能修改"]);
            return false;
        }
    }

    try
    {
        if (changedType == ItemChangedType.Update)
            await _repo.UpdateAsync(item);
        else
            await _repo.InsertAsync(item);
    }
    catch (Exception ex)
    {
        await MessageService.Error(string.Format(CommonLocalizer["保存数据时发生错误:{0}"], ex.Message));
        return false;
    }
    // 5) 审批自动提交(默认不自动,由流程配置开启)见下文
}

这段代码回答了三个常见疑问。

为什么"隐藏按钮"不等于"不能提交"?

因为按钮显隐只是 UI 层。真正的拦截在 AuthButton(action) 这一行------即使有人伪造事件直接触发保存,也会被服务端挡下来。

为什么改了 Id 也改不了别人的数据?

因为更新前会走 FilterAuthorizedAsync,按主键回查一次"这条记录在当前用户的数据权限范围内吗"。只检查按钮权限是不够的。

为什么自定义了 OnSaveAsync,权限还在?

OnParametersSetAsync 会把页面传入的自定义保存回调包一层 SaveWithPermissionAsync,删除同理包 DeleteWithPermissionAsync。包装后仍会先做 AuthButton 判定,再执行你的逻辑。


四、删除:软删除和物理删除是两条路

OnDeleteDataAsync 的关键分支:

csharp 复制代码
if (typeof(IEntitySoftDelete).IsAssignableFrom(typeof(TItem)))
{
    // 软删除实体:标记 IsDeleted=true 而不是物理删除,便于恢复
    items = await FilterAuthorizedAsync(items, CommonLocalizer["没有权限操作部分数据,已取消删除"]);
    if (items.Count == 0) return false;

    var deleteIds = items.Select(i => i.Id).ToList();
    await _repo.Orm.Update<TItem>()
        .SetByPropertyName("IsDeleted", true)
        .Where(x => deleteIds.Contains(x.Id))
        .ExecuteAffrowsAsync();
}
else
{
    // 物理删除
    items = await FilterAuthorizedAsync(items, CommonLocalizer["没有权限操作部分数据,已取消删除"]);
    if (items.Count == 0) return false;

    await _repo.DeleteAsync(items);
}

要点:

  • 继承 EntityFull 的实体是软删除(EntityFullEntitySoftDelete),继承 EntityCreated / Entity 的是物理删除。
  • 批量删除先过滤再执行:只要有一行越权就把越权的剔除;全部越权时直接取消,并提示"没有权限操作部分数据"。
  • 软删除的实体会被全局过滤器自动排除,查询看不到,但数据还在,适合需要恢复的场景。
  • 删除前还有审批检查:审批中的单据禁止删除。

五、导出:为什么不是简单的 ToList 再写 Excel

导出走 OnExportAllAsync,这条链路比想象中讲究:

csharp 复制代码
var select = GetSelect();
if (OnBeforeQuery.HasDelegate)
{
    // 注意:导出时 IsExport = true
    await OnBeforeQuery.InvokeAsync(new AdminQueryEventArgs<TItem>(select, context.Options) { IsExport = true });
}

var columns = GetExportColumns().ToList();
...
// 只查询导出列(动态投影),避免 SELECT * 把大文本/导航数据全部拉回来;
// 查询链路不变(OnBeforeQuery 过滤 + 数据权限 + 动态过滤 + 排序),Include 不会执行
rows = await LoadExportRowsAsync(select, columns, context.Options, lookupService, exportOptions);

几个设计点:

  1. 导出走同一套过滤逻辑。数据权限、动态筛选、排序都保留,不会出现"界面看不到但导出能看到"的越权导出。
  2. 动态投影只查需要的列。LoadExportRowsAsyncReflection.Emit 按字段名动态生成一个 DTO 类型(带缓存),再用 FreeSql 投影查询,避免把大文本、导航集合拉回来。
  3. 投影失败有回退。catch 之后回退到整实体查询 + BuildExportRowsAsync,保证导出不会因为实体结构特殊而直接崩。
  4. 最终用 MiniExcel 写流,并配合 IsExport = true 让页面在 OnBeforeQuery 里能区分导出和普通查询(比如导出时不 Include,避免导航数据干扰)。

GetExportColumns() 也重写了:默认按当前可见列导出,而不是实体所有属性。想导出的列没出来,先检查它是不是被 IgnoreWhenExport 或可见性设置排除了。


六、导入:Excel 不只是"读进来写库"

导入弹窗的流程在 ShowImportDialog

text 复制代码
DropUpload(.xlsx)
   → 文件大小校验(MaxFileLength = 5MB)
   → AuthButton("add")                    服务端权限校验
   → memoryStream.Query<TItem>()          MiniExcel 读成实体
   → OnBeforeImportAsync                  页面自定义校验
   → FilterAuthorizedImportRowsAsync      数据权限逐行过滤
   → InsertOrUpdate + UpdateColumns       默认导入实现
   → OnFinishImportAsync / 操作日志 / 重新查询

默认实现这一段值得单独看:

csharp 复制代码
var updateColumns = GetExportColumns()
    .Where(x => x.IsVisibleWhenAdd != false)
    .Select(x => x.GetFieldName())
    .ToArray();

// 数据权限:Excel 导入/更新同样不能操作当前用户无权限的数据。
// Id > 0 的行会走数据库按主键更新,因此必须逐行确认该记录在当前用户的数据权限范围内,
// 不能只依赖前端按钮权限(否则伪造 Id 即可越权更新他人数据)
rows = await FilterAuthorizedImportRowsAsync(rows);
if (rows.Count == 0) return;

affectedRows = await _repo.Orm.InsertOrUpdate<TItem>()
    .SetSource(rows)
    .UpdateColumns(updateColumns)
    .ExecuteAffrowsAsync();

两个很实际的点:

  • 导入默认是 InsertOrUpdate:Excel 里 Id > 0 的行按主键更新,Id = 0 的行新增;更新哪些列由可见列决定,不会因为 Excel 里多一列就写到数据库。
  • 导入路径也必须做数据权限。这是最容易被忽略的越权入口:Excel 里写一个别人的 Id,如果只校验按钮权限,就能改别人的数据。所以这里会逐行过滤,并提示"已忽略 N 条没有权限操作的数据"。

导入有自定义逻辑时用 OnImportAsync 完全接管,但要注意:接管之后数据权限过滤需要你自己做(默认实现里那段过滤就不会执行了)。


七、还有几个藏在源码里的实用能力

草稿自动保存

EnableDraft=true 时,编辑弹窗会按 DraftAutoSaveInterval(默认 30 秒)自动把表单序列化成草稿,键由实体类型 + 变更类型 + 主键生成:

csharp 复制代码
private string GetDraftKey(ItemChangedType changedType, TItem? model)

保存成功后调用 ClearDraft(),避免下次打开时恢复出旧内容。完整介绍见已发布的第九篇。

保存不关闭弹窗

EnableSaveWithoutClose 适合"填完一条想接着填下一条"的场景;实现上是保存成功后清空表单重新进入新增态,而不是关闭弹窗。

打印

ShowPrintButton 配合 SysPrintTemplate 使用。进入表格时会按实体全名加载对应模板,支持单个模板直接出按钮、多个模板出下拉。

树形表格

GetParentId 传一个委托,AdminTable 会把平铺数据转成 TableTreeNode

csharp 复制代码
private static Task<IEnumerable<TableTreeNode<TItem>>> TableTreeNodeConverter(
    IEnumerable<TItem> items, Func<TItem, TKey>? getParentId)

适合部门、菜单、分类这类自关联结构。


八、什么时候要小心

  1. OnBeforeQuery 里做 Include 时,记得配合 IgnoreSearchColumns。导航集合被当成筛选字段传给 FreeSql 会报"无法匹配 xxx"。
  2. 自定义 OnSaveAsync / OnDeleteAsync 会绕过默认的数据权限处理(权限校验会被包装保留,但数据权限回查不会)。需要自己补 FilterAuthorizedAsync 等价逻辑。
  3. 自定义 OnImportAsync 同理,默认的数据权限过滤不会自动执行。
  4. 导出大表时注意内存。源码已经做了列投影和流式写出,但一次性导出全表仍然会占用相应内存,建议配合 OnBeforeQuery 限制范围或做异步导出。
  5. 实体必须能被 FreeSql 正确映射。AdminTable 的查询、排序、动态筛选都建立在 CodeFirst.GetTableByEntity 之上,属性没有映射信息时行为不可预期。

九、小结

AdminTable 的职责拆开看,它其实就做了四件事:

能力 实现位置
把表格查询接到 FreeSql GetSelect + OnQueryDataAsync + GetPagedAsync
把 BootstrapBlazor 的筛选/排序翻译成 SQL ToDynamicFilter + ApplyOrder + ProcessFlagsFilters
在写路径上做服务端权限与数据权限校验 AuthButton + FilterAuthorizedAsync
把导入导出、打印、草稿这些后台刚需补齐 OnExportAllAsync / ShowImportDialog / 草稿相关方法

理解了这四件事,"通用 CRUD"就不再是黑盒:界面上的每一个按钮、每一次查询、每一次导入,你都能指到对应的源码位置。


如果你正在用 .NET 10 + Blazor 开发后台系统,可以看看 EasyAdminBlazor 的 AdminTable:它把后台最重复的查询、分页、权限、导入导出做成了组件能力,一个实体加一个页面就是一个模块。

相关推荐
知守观1 小时前
Spring AOP + 自定义注解实现三角色权限控制:从设计到落地的完整方案
后端
Mikko71 小时前
jackson-databind 升到 2.21.6 就安全了吗?jackson-core 是另一个坐标,它那条 high 全局库至今没收
java·后端·安全·json
Ticnix1 小时前
我调了三个月 overlap=50,它其实一次都没生效
后端·python·agent
斯维赤1 小时前
LangChain4j 入门教学(Java 后端狂喜版
java·后端
数据库小学妹1 小时前
MySQL库存扣减实战:原子UPDATE、分桶方案与锁范围分析
数据库·后端·mysql
旺仔不是程序员1 小时前
LIMIT 1:PostgreSQL 只取一行的高效查询姿势
数据库·后端·sql
知守观1 小时前
Java POI 动态二级表头导出实战:并集计算 + 合并单元格 + 冻结列的完整实现
后端
知守观1 小时前
Snowflake 雪花算法实战:41位时间戳里的三个坑(时钟回拨、workerId、位运算)
后端