本文对应版本:EasyAdminBlazor 2.3.0+。
源码位置:
EasyAdminBlazor/Components/AdminTable.razorEasyAdminBlazor/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
这段声明里有三个关键信息:
- 泛型约束要求实体必须实现
IEntity<TKey>。框架里的Entity/EntityCreated/EntityFull都已经满足,正常实体不用额外处理。 - 自动注入
IAggregateRootRepository<TItem>,GetSelect()就是把它暴露出来:
csharp
public ISelect<TItem> GetSelect()
{
return _repo.Select;
}
- 表格渲染直接走 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 做了什么
GetPagedAsync 在 FreeSqlExtensions.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
};
}
几个细节:
- 搜索、高级搜索、自定义搜索、表头过滤统一转成
DynamicFilterInfo。ToDynamicFilter把Searches(模糊搜索)用Or组合,把CustomerSearches/AdvanceSearches/Filters追加为条件,最终交给 FreeSql 的WhereDynamicFilter。 - 排序交给
ApplyOrder:优先用表格传来的SortList或SortName,没有排序配置时保留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的实体是软删除(EntityFull→EntitySoftDelete),继承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);
几个设计点:
- 导出走同一套过滤逻辑。数据权限、动态筛选、排序都保留,不会出现"界面看不到但导出能看到"的越权导出。
- 动态投影只查需要的列。
LoadExportRowsAsync用Reflection.Emit按字段名动态生成一个 DTO 类型(带缓存),再用 FreeSql 投影查询,避免把大文本、导航集合拉回来。 - 投影失败有回退。
catch之后回退到整实体查询 +BuildExportRowsAsync,保证导出不会因为实体结构特殊而直接崩。 - 最终用 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)
适合部门、菜单、分类这类自关联结构。
八、什么时候要小心
OnBeforeQuery里做 Include 时,记得配合IgnoreSearchColumns。导航集合被当成筛选字段传给 FreeSql 会报"无法匹配 xxx"。- 自定义
OnSaveAsync/OnDeleteAsync会绕过默认的数据权限处理(权限校验会被包装保留,但数据权限回查不会)。需要自己补FilterAuthorizedAsync等价逻辑。 - 自定义
OnImportAsync同理,默认的数据权限过滤不会自动执行。 - 导出大表时注意内存。源码已经做了列投影和流式写出,但一次性导出全表仍然会占用相应内存,建议配合
OnBeforeQuery限制范围或做异步导出。 - 实体必须能被 FreeSql 正确映射。
AdminTable的查询、排序、动态筛选都建立在CodeFirst.GetTableByEntity之上,属性没有映射信息时行为不可预期。
九、小结
把 AdminTable 的职责拆开看,它其实就做了四件事:
| 能力 | 实现位置 |
|---|---|
| 把表格查询接到 FreeSql | GetSelect + OnQueryDataAsync + GetPagedAsync |
| 把 BootstrapBlazor 的筛选/排序翻译成 SQL | ToDynamicFilter + ApplyOrder + ProcessFlagsFilters |
| 在写路径上做服务端权限与数据权限校验 | AuthButton + FilterAuthorizedAsync |
| 把导入导出、打印、草稿这些后台刚需补齐 | OnExportAllAsync / ShowImportDialog / 草稿相关方法 |
理解了这四件事,"通用 CRUD"就不再是黑盒:界面上的每一个按钮、每一次查询、每一次导入,你都能指到对应的源码位置。
如果你正在用 .NET 10 + Blazor 开发后台系统,可以看看 EasyAdminBlazor 的 AdminTable:它把后台最重复的查询、分页、权限、导入导出做成了组件能力,一个实体加一个页面就是一个模块。