
本文对应版本EasyAdminBlazor 2.3.0。源码位置EasyAdminBlazor/Components/AdminTable.razorEasyAdminBlazor/Infrastructure/FreeSql/FreeSqlExtensions.cs示例页面EasyAdminBlazor.Test/Components/Admin/Product.razor已发布过的《几十行代码搞定 CRUD》讲的是怎么用这篇讲的是它到底替你做了什么。读完你应该能在出问题时判断是查询条件的问题、权限的问题还是 ORM 映射的问题。一、先认清 AdminTable 的身份AdminTable直接继承 BootstrapBlazor 的TableTItemnamespace BootstrapBlazor.Components implements IDisposable inherits TableTItem attribute [CascadingTypeParameter(nameof(TItem))] typeparam TItem where TItem : class, IEntityTKey, new() typeparam TKey where TKey : IEquatableTKey inject IAggregateRootRepositoryTItem _repo inject ITableExport TableExport inject NavigationManager NavigationManager inject EasyAdminBlazor.PrintService PrintService inject EasyAdminBlazor.IApprovalGateway ApprovalGateway这段声明里有三个关键信息泛型约束要求实体必须实现IEntityTKey。框架里的Entity/EntityCreated/EntityFull都已经满足正常实体不用额外处理。自动注入IAggregateRootRepositoryTItemGetSelect()就是把它暴露出来publicISelectTItemGetSelect(){return_repo.Select;}表格渲染直接走 BootstrapBlazor{ base.BuildRenderTree(__builder); }。AdminTable只负责把数据、权限、导入导出这些后台语义接上UI 能力仍然复用 BootstrapBlazor。也就是说几十行代码搞定 CRUD不是魔法而是实体 仓储 表格 约定四件事被组装好了。二、一次查询是怎么跑起来的1. 组件初始化时接管回调OnParametersSetAsync是整个组件的装配点protectedoverrideasyncTaskOnParametersSetAsync(){// 实体实现 IDataPermission IEntityCreated 时自动启用数据权限_autoDataPermissiontypeof(IDataPermission).IsAssignableFrom(typeof(TItem))typeof(IEntityCreated).IsAssignableFrom(typeof(TItem));// 实体实现 IApprovalBill 时自动接管审批_autoApprovaltypeof(IApprovalBill).IsAssignableFrom(typeof(TItem));// 记录页面显式传入的委托页面自定义回调需要额外包一层权限校验varcustomSaveAsync_writePermissionWrapped?null:OnSaveAsync;varcustomDeleteAsync_writePermissionWrapped?null:OnDeleteAsync;// ⚠️ 必须在任何 await 之前赋值否则首次渲染会以 null 回调触发查询if(OnQueryAsyncnullItemsnull){OnQueryAsyncOnQueryDataAsync;_queryingtrue;}this.GetIgnoredPropertyNames();if(OnSaveAsyncnull)OnSaveAsync_defaultSaveAsync??OnSaveDataAsync;elseif(customSaveAsync!null!ReferenceEquals(customSaveAsync,_defaultSaveAsync)){varoriginalSavecustomSaveAsync;OnSaveAsync(item,changedType)SaveWithPermissionAsync(originalSave,item,changedType);_writePermissionWrappedtrue;}// 删除同理}源码里那行注释不是摆设。Blazor 会在第一个await处触发首次渲染如果此时OnQueryAsync还是null首屏就会出现查不到数据的假象。这是AdminTable里最容易踩、也最难从现象看出来的一类坑。2. 默认查询数据权限 动态筛选 排序 分页真正的查询在OnQueryDataAsyncprivateasyncTaskQueryDataTItemOnQueryDataAsync(QueryPageOptionsoptions){_queryingtrue;awaitInvokeAsync(StateHasChanged);try{varselectGetSelect();if(OnBeforeQuery.HasDelegate){awaitOnBeforeQuery.InvokeAsync(newAdminQueryEventArgsTItem(select,options));}returnawaitselect.ApplyDataPermission(admin,UseDataPermission||_autoDataPermission).GetPagedAsyncTItem,TKey(options,_IgnoreSearchColumns.ToArray());}finally{_queryingfalse;awaitInvokeAsync(StateHasChanged);}}调用链可以画成AdminTable.OnQueryDataAsync → GetSelect() FreeSql ISelect_repo.Select → OnBeforeQuery 页面自定义条件Include / Join / Where → ApplyDataPermission 数据权限过滤第 09 篇详解 → GetPagedAsync 动态筛选 排序 Count 分页3. GetPagedAsync 做了什么GetPagedAsync在FreeSqlExtensions.cs是 BootstrapBlazor 的QueryPageOptions和 FreeSql 之间的桥publicstaticasyncTaskQueryDataTGetPagedAsyncT,TKey(thisISelectTselect,QueryPageOptionsoptions,paramsstring[]ignoreColumns)whereT:class,IEntityTKey{// 把 QueryPageOptions 转成 FreeSql 的 DynamicFilterInfovardynamicFilteroptions.ToDynamicFilter(ignoreColumns);// Flags Enum 的过滤要换成位运算 SQLdynamicFilterProcessFlagsFilters(select,typeof(T),dynamicFilter);varqueryselect.WhereDynamicFilter(dynamicFilter).ApplyOrderT,TKey(options).Count(outvarcount);varitemsoptions.IsPage?awaitquery.Page(options.PageIndex,options.PageItems).ToListAsync():awaitquery.ToListAsync();returnnewQueryDataT(){Itemsitems,TotalCountConvert.ToInt32(count),IsFilteredtrue,IsSearchtrue};}几个细节搜索、高级搜索、自定义搜索、表头过滤统一转成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// 使用位运算判断是否包含该位if(val0)select.Where($(a.{columnName}{val}) ! 0);returnnull;// 从树中移除该节点避免 WhereDynamicFilter 重复处理所以筛选原创能筛出原创 转载的记录。这段逻辑只对标记了[Flags]的枚举生效。三、新增和编辑不只是 Insert 和 Update默认保存在OnSaveDataAsync。顺序是这样的privateasyncTaskboolOnSaveDataAsync(TItemitem,ItemChangedTypechangedType){// 1) 服务端写操作校验与 UI 按钮显隐使用同一权限判定管理员自动放行varactionchangedTypeItemChangedType.Add?add:edit;if(!awaitadmin.AuthButton(action)){awaitMessageService.Error(string.Format(CommonLocalizer[没有权限执行该操作]));returnfalse;}// 2) 数据权限更新时必须校验该记录是否属于当前用户可操作范围if(changedTypeItemChangedType.Update){varauthorizedawaitFilterAuthorizedAsync([item],CommonLocalizer[没有权限修改该数据]);if(authorized.Count0)returnfalse;}// 3) 页面回调if(OnBeforeSaveAsync.HasDelegate){varargsnewAdminSaveEventArgsTItem{Itemitem,ChangedTypechangedType};awaitOnBeforeSaveAsync.InvokeAsync(args);if(args.Cancel)returnfalse;}// 4) 审批中的单据不允许修改if(_autoApproval){varcheckawaitApprovalGateway.CheckModifyAsync(item);if(!check.Allowed){awaitMessageService.Error(check.Message??CommonLocalizer[单据正在审批中不能修改]);returnfalse;}}try{if(changedTypeItemChangedType.Update)await_repo.UpdateAsync(item);elseawait_repo.InsertAsync(item);}catch(Exceptionex){awaitMessageService.Error(string.Format(CommonLocalizer[保存数据时发生错误{0}],ex.Message));returnfalse;}// 5) 审批自动提交默认不自动由流程配置开启见下文}这段代码回答了三个常见疑问。为什么隐藏按钮不等于不能提交因为按钮显隐只是 UI 层。真正的拦截在AuthButton(action)这一行——即使有人伪造事件直接触发保存也会被服务端挡下来。为什么改了Id也改不了别人的数据因为更新前会走FilterAuthorizedAsync按主键回查一次这条记录在当前用户的数据权限范围内吗。只检查按钮权限是不够的。为什么自定义了OnSaveAsync权限还在OnParametersSetAsync会把页面传入的自定义保存回调包一层SaveWithPermissionAsync删除同理包DeleteWithPermissionAsync。包装后仍会先做AuthButton判定再执行你的逻辑。四、删除软删除和物理删除是两条路OnDeleteDataAsync的关键分支if(typeof(IEntitySoftDelete).IsAssignableFrom(typeof(TItem))){// 软删除实体标记 IsDeletedtrue 而不是物理删除便于恢复itemsawaitFilterAuthorizedAsync(items,CommonLocalizer[没有权限操作部分数据已取消删除]);if(items.Count0)returnfalse;vardeleteIdsitems.Select(ii.Id).ToList();await_repo.Orm.UpdateTItem().SetByPropertyName(IsDeleted,true).Where(xdeleteIds.Contains(x.Id)).ExecuteAffrowsAsync();}else{// 物理删除itemsawaitFilterAuthorizedAsync(items,CommonLocalizer[没有权限操作部分数据已取消删除]);if(items.Count0)returnfalse;await_repo.DeleteAsync(items);}要点继承EntityFull的实体是软删除EntityFull→EntitySoftDelete继承EntityCreated/Entity的是物理删除。批量删除先过滤再执行只要有一行越权就把越权的剔除全部越权时直接取消并提示没有权限操作部分数据。软删除的实体会被全局过滤器自动排除查询看不到但数据还在适合需要恢复的场景。删除前还有审批检查审批中的单据禁止删除。五、导出为什么不是简单的 ToList 再写 Excel导出走OnExportAllAsync这条链路比想象中讲究varselectGetSelect();if(OnBeforeQuery.HasDelegate){// 注意导出时 IsExport trueawaitOnBeforeQuery.InvokeAsync(newAdminQueryEventArgsTItem(select,context.Options){IsExporttrue});}varcolumnsGetExportColumns().ToList();...// 只查询导出列动态投影避免 SELECT * 把大文本/导航数据全部拉回来// 查询链路不变OnBeforeQuery 过滤 数据权限 动态过滤 排序Include 不会执行rowsawaitLoadExportRowsAsync(select,columns,context.Options,lookupService,exportOptions);几个设计点导出走同一套过滤逻辑。数据权限、动态筛选、排序都保留不会出现界面看不到但导出能看到的越权导出。动态投影只查需要的列。LoadExportRowsAsync用Reflection.Emit按字段名动态生成一个 DTO 类型带缓存再用 FreeSql 投影查询避免把大文本、导航集合拉回来。投影失败有回退。catch之后回退到整实体查询 BuildExportRowsAsync保证导出不会因为实体结构特殊而直接崩。最终用 MiniExcel 写流并配合IsExport true让页面在OnBeforeQuery里能区分导出和普通查询比如导出时不 Include避免导航数据干扰。GetExportColumns()也重写了默认按当前可见列导出而不是实体所有属性。想导出的列没出来先检查它是不是被IgnoreWhenExport或可见性设置排除了。六、导入Excel 不只是读进来写库导入弹窗的流程在ShowImportDialogDropUpload.xlsx → 文件大小校验MaxFileLength 5MB → AuthButton(add) 服务端权限校验 → memoryStream.QueryTItem() MiniExcel 读成实体 → OnBeforeImportAsync 页面自定义校验 → FilterAuthorizedImportRowsAsync 数据权限逐行过滤 → InsertOrUpdate UpdateColumns 默认导入实现 → OnFinishImportAsync / 操作日志 / 重新查询默认实现这一段值得单独看varupdateColumnsGetExportColumns().Where(xx.IsVisibleWhenAdd!false).Select(xx.GetFieldName()).ToArray();// 数据权限Excel 导入/更新同样不能操作当前用户无权限的数据。// Id 0 的行会走数据库按主键更新因此必须逐行确认该记录在当前用户的数据权限范围内// 不能只依赖前端按钮权限否则伪造 Id 即可越权更新他人数据rowsawaitFilterAuthorizedImportRowsAsync(rows);if(rows.Count0)return;affectedRowsawait_repo.Orm.InsertOrUpdateTItem().SetSource(rows).UpdateColumns(updateColumns).ExecuteAffrowsAsync();两个很实际的点导入默认是 InsertOrUpdateExcel 里Id 0的行按主键更新Id 0的行新增更新哪些列由可见列决定不会因为 Excel 里多一列就写到数据库。导入路径也必须做数据权限。这是最容易被忽略的越权入口Excel 里写一个别人的Id如果只校验按钮权限就能改别人的数据。所以这里会逐行过滤并提示已忽略 N 条没有权限操作的数据。导入有自定义逻辑时用OnImportAsync完全接管但要注意接管之后数据权限过滤需要你自己做默认实现里那段过滤就不会执行了。七、还有几个藏在源码里的实用能力草稿自动保存EnableDrafttrue时编辑弹窗会按DraftAutoSaveInterval默认 30 秒自动把表单序列化成草稿键由实体类型 变更类型 主键生成privatestringGetDraftKey(ItemChangedTypechangedType,TItem?model)保存成功后调用ClearDraft()避免下次打开时恢复出旧内容。完整介绍见已发布的第九篇。保存不关闭弹窗EnableSaveWithoutClose适合填完一条想接着填下一条的场景实现上是保存成功后清空表单重新进入新增态而不是关闭弹窗。打印ShowPrintButton配合SysPrintTemplate使用。进入表格时会按实体全名加载对应模板支持单个模板直接出按钮、多个模板出下拉。树形表格给GetParentId传一个委托AdminTable会把平铺数据转成TableTreeNodeprivatestaticTaskIEnumerableTableTreeNodeTItemTableTreeNodeConverter(IEnumerableTItemitems,FuncTItem,TKey?getParentId)适合部门、菜单、分类这类自关联结构。八、什么时候要小心OnBeforeQuery里做 Include 时记得配合IgnoreSearchColumns。导航集合被当成筛选字段传给 FreeSql 会报无法匹配 xxx。自定义OnSaveAsync/OnDeleteAsync会绕过默认的数据权限处理权限校验会被包装保留但数据权限回查不会。需要自己补FilterAuthorizedAsync等价逻辑。自定义OnImportAsync同理默认的数据权限过滤不会自动执行。导出大表时注意内存。源码已经做了列投影和流式写出但一次性导出全表仍然会占用相应内存建议配合OnBeforeQuery限制范围或做异步导出。实体必须能被 FreeSql 正确映射。AdminTable的查询、排序、动态筛选都建立在CodeFirst.GetTableByEntity之上属性没有映射信息时行为不可预期。九、小结把AdminTable的职责拆开看它其实就做了四件事能力实现位置把表格查询接到 FreeSqlGetSelectOnQueryDataAsyncGetPagedAsync把 BootstrapBlazor 的筛选/排序翻译成 SQLToDynamicFilterApplyOrderProcessFlagsFilters在写路径上做服务端权限与数据权限校验AuthButtonFilterAuthorizedAsync把导入导出、打印、草稿这些后台刚需补齐OnExportAllAsync/ShowImportDialog/ 草稿相关方法理解了这四件事通用 CRUD就不再是黑盒界面上的每一个按钮、每一次查询、每一次导入你都能指到对应的源码位置。如果你正在用 .NET 10 Blazor 开发后台系统可以看看 EasyAdminBlazor 的 AdminTable它把后台最重复的查询、分页、权限、导入导出做成了组件能力一个实体加一个页面就是一个模块。文档https://easyadmin.wang-zhan.com.cn/doc源码https://gitee.com/gudufy/EasyAdminBlazor