后台开发里关联表几乎是必选项:订单要显示客户名、文章要选专栏、用户要分配角色、菜单要挂父子。这篇讲 EasyAdminBlazor 里关联数据从建模到查询、展示、编辑的完整做法。
一、四种关联,四种 Navigate 写法
FreeSql 用[Navigate]描述关联,框架的实体已经给出了四类范例。
1. 多对一(最常用)
文章属于一个专栏:
[Table(Name="blog_article")]publicpartialclassArticle:ApprovalEntityFull{/// <summary>随笔专栏</summary>[DisplayName("随笔专栏")][Required]publiclong?ClassifyId{get;set;}publicClassifyClassify{get;set;}=default!;}这里ClassifyId是外键列,Classify是导航属性。注意仓库里的示例实体并没有在Classify上写[Navigate(nameof(ClassifyId))]——FreeSql 会按命名约定自动识别ClassifyId与Classify的关系。如果你的命名不符合约定,就显式标注:
[Navigate(nameof(ClassifyId))]publicClassifyClassify{get;set;}=default!;2. 一对多(反向导航)
partialclassSysUser{[Navigate(nameof(SysRoleUser.UserId))][JsonIgnore]publicList<SysRoleUser>RoleUsers{get;set;}=[];}3. 多对多
publicclassSysRole:Entity{[JsonIgnore][Navigate(ManyToMany=typeof(SysRoleUser))]publicList<SysUser>Users{get;set;}=[];[JsonIgnore][Navigate(ManyToMany=typeof(SysRoleMenu))]publicList<SysMenu>Menus{get;set;}=[];}中间表是显式定义的实体:
publicclassSysRoleMenu{publiclongRoleId{get;set;}publiclongMenuId{get;set;}publicSysRoleRole{get;set;}=default!;publicSysMenuMenu{get;set;}=default!;}4. 自关联(树形)
publicpartialclassSysMenu:EntityCreated,IHasParentId<long>{[Navigate(nameof(ParentId))][JsonIgnore]publicSysMenu?Parent{get;set;}[Navigate(nameof(ParentId))][JsonIgnore]publicList<SysMenu>Childs{get;set;}=[];publiclongParentId{get;set;}}注意导航属性上的[JsonIgnore]:这些是 ORM 层面的对象图,序列化给前端时容易产生循环引用或体积膨胀,所以框架统一加了忽略。
二、查询:Include 与 IncludeMany
1. 多对一用 Include
privatevoidOnBeforeQuery(AdminQueryEventArgs<Article>e){e.Select.Include(a=>a.Classify);}2. 多对多用 IncludeMany
角色页需要把"这个角色有哪些菜单"一起查出来(因为点击行时要回显勾选状态):
privatevoidOnBeforeQuery(AdminQueryEventArgs<SysRole>e){e.Select.WhereIf(!admin.IsAdmin,x=>x.IsAdministrator==false);if(!e.IsExport){e.Select.IncludeMany(x=>x.Menus);}}两行代码包含两个经验:
WhereIf做条件过滤:非管理员看不到超级管理员角色;- 导出时跳过
IncludeMany:IsExport判断避免了"导出上千行时把每行的菜单集合都拉一遍"的灾难性性能问题(AdminQueryEventArgs的注释里明确写了这一点)。
3. 不带导航的查询默认更轻
如果只是要显示"分类 Id"而不是"分类名称",就不要Include。导航属性的加载是有成本的,尤其是在列表页。
三、表格展示关联名称
关联名称不能直接绑ClassifyId,要用Template取导航属性:
<TableColumn @bind-Field="context.ClassifyId" Filterable="true"> <Template Context="v">@v.Row.Classify?.ClassifyName</Template> <FilterTemplate> <FilterProvider> <AdminSelectEntityFilter TItem="Classify" GetText="x => x.ClassifyName" /> </FilterProvider> </FilterTemplate> </TableColumn>三件事同时完成:
- 列绑定的是外键字段
ClassifyId(这样排序、筛选都作用在数据库列上); - 显示的是导航属性
Classify?.ClassifyName; - 列头筛选用
AdminSelectEntityFilter选一个专栏。
?.不是多余的:如果某行的外键指向一个已被删除的分类,导航属性就是 null,加了?.才不会抛异常。
按关联字段模糊搜索
想按"分类名称"模糊搜索,用AdminSelectEntityFilterGeneric:
<TableColumn @bind-Field="context.Title" Filterable="true" Searchable="true"> <FilterTemplate> <FilterProvider> <AdminSelectEntityFilterGeneric TItem="Classify" TKey="string" FilterAction="FilterAction.Contains" GetValue="a=>a.ClassifyName" GetText="x => x.ClassifyName" /> </FilterProvider> </FilterTemplate> </TableColumn>GetValue决定筛选值取什么(这里取分类名),FilterAction.Contains决定是模糊匹配。
四、表单里选关联:三个组件
1. AdminSelectEntity:只绑 Id
<AdminSelectEntity TItem="Classify" TKey="long?" @bind-Value="Model.ClassifyId" GetText="e => e.ClassifyName" ShowSearch />参数(源码):
[Parameter]publicExpression<Func<TItem,bool>>?Where{get;set;}[Parameter]publicFunc<TItem,string>GetText{get;set;}=x=>x?.ToString()??string.Empty;[Parameter]publicFunc<TItem,string>?GetValue{get;set;}[Parameter]publicTimeSpanCacheDuration{get;set;}=TimeSpan.FromSeconds(30);注意CacheDuration默认是 30 秒。
2. AdminSelectTable:绑整个实体
需要把选中的实体对象也带回来(比如要读它的多个字段)时用这个:
<AdminSelectTable TItem="SysUser" @bind-ValueId="@selectedUserId" GetText="@(u => u.Nickname)"> <TableColumns> <TableColumn @bind-Field="context.Username" Text="用户名" /> <TableColumn @bind-Field="context.Nickname" Text="昵称" /> </TableColumns> </AdminSelectTable>它同时支持@bind-Value(完整实体)和@bind-ValueId(主键),弹窗里的列可以自定义。
3. 是否需要数据权限
AdminSelectEntity/AdminMultiSelect/AdminSelectTable都支持UseDataPermission,用法与AdminTable一致:
<AdminSelectEntity TItem="SysUser" TKey="long" @bind-ValueId="Model.AuditorId" GetText="u => u.Nickname" UseDataPermission="true" />打开之后,"能选谁"就受当前用户的数据权限约束,避免选到一个自己根本无权查看的人。
五、多对多编辑:角色分配菜单的真实实现
Pages/Role.razor是一个完整的多对多编辑范例:左边角色表格,右边菜单树。
1. 点击行,回显已选
privateasyncTaskOnClickRowCallback(SysRolerow){select=row;varroleMenuIds=row.Menus.Select(m=>m.Id).ToList();menuSelectionTree?.UpdateTreeSelection(roleMenuIds);awaitInvokeAsync(StateHasChanged);}row.Menus之所以有值,是因为查询时IncludeMany(x => x.Menus)已经加载了。
2. 保存,更新关联表
[AdminButton("alloc_menus")][OperationLog("修改角色菜单权限")]privateasyncTaskOnSaveMenu(){if(select!=null){if(select.Menus==null||select.Menus.Count==0){awaitSwalService.Warning(CommonLocalizer["请至少选择一个菜单权限"]);return;}await_repo.UpdateAsync(select);awaitadmin.InvalidatePermissionCacheAsync();awaitToastService.Success(CommonLocalizer["保存数据"],CommonLocalizer["权限保存成功"]);}}三个关键点:
[AdminButton("alloc_menus")]:方法级权限,没有这个按钮权限直接拦截;[OperationLog("修改角色菜单权限")]:审计留痕;InvalidatePermissionCacheAsync():角色-菜单关系变了,权限缓存必须立即失效,否则用户要等 30 分钟才生效。
3. 删除前的引用检查
删除角色前要确认没人用它:
varroleIds=e.Items.Select(x=>x.Id).ToList();varusedCount=await_repo.Orm.Select<SysRoleUser>().Where(x=>roleIds.Contains(x.RoleId)).CountAsync();if(usedCount>0){awaitSwalService.Error(CommonLocalizer["该角色已分配给用户,不能删除"]);e.Cancel=true;}这是关联表最典型的"引用完整性"处理:先查中间表,有人用就拦住删除。
六、IgnoreSearchColumns:导航属性不能当筛选列
这是关联表最容易踩的坑。页面里只要有导航集合,就要在AdminTable上声明忽略:
<AdminTable TItem="SysUser" TKey="long" IgnoreSearchColumns="x => new { x.Roles, x.Messages }" ... />原因在第 05 篇讲过:框架会把表格的筛选条件翻译成数据库条件,导航属性不是数据库列,传下去会报"无法匹配 xxx"。
IgnoreSearchColumns在OnParametersSetAsync的最前面就会被解析(GetIgnoredPropertyNames()),保证首次渲染抢跑查询时列表已经就绪。
七、性能:关联查询的几个取舍
| 做法 | 代价 | 建议 |
|---|---|---|
列表页Include多对一 | 每条多一次 JOIN | 需要显示关联名称时才加 |
列表页IncludeMany多对多 | 数据量随关联数放大 | 只在确实需要时用(如角色页回显菜单) |
导出时Include | 行数 × 关联数,非常慢 | 用IsExport跳过 |
关联筛选(AdminSelectEntityFilterGeneric) | 子查询/EXISTS | 关联表的外键列建索引 |
| 导航属性被当作筛选字段 | 直接报错 | 加入IgnoreSearchColumns |
一条经验:列表页尽量只查需要的列,关联名称用投影或按需加载;编辑页拿到完整对象后,导航加载可以更宽松。
八、主子表(明细)怎么办
先说实话:框架没有专门的"主从表/明细表"组件。EditTemplate只是一个普通的 Blazor 组件插槽。
如果需要"一张订单下面有多条明细",可行的做法是:
<!-- 订单编辑模板里嵌一个绑定当前订单的明细表格 --> <AdminTable TItem="OrderItem" TKey="long" OnBeforeQuery="OnBeforeQueryItems" ... /> @code { private void OnBeforeQueryItems(AdminQueryEventArgs<OrderItem> e) { e.Select.Where(x => x.OrderId == Model.Id); // 只查当前单据的明细 } }要注意两点:
- 主单据尚未保存(
Id = 0)时没有明细可挂,通常做法是"先保存主表,再编辑明细"; - 明细的保存/删除要自己处理与主表的从属关系(级联删除、必填校验等)。
这属于业务编排,框架提供的是组件能力而不是固定模式。
九、常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| 关联名称为空 | 导航属性没加载 | 在OnBeforeQuery里Include |
| 报"无法匹配 xxx" | 导航属性参与了筛选 | 加入IgnoreSearchColumns |
| 多对多保存没生效 | 只改了主表,没更新中间表 | 参考Role.razor:IncludeMany+UpdateAsync |
| 改了角色/菜单权限不生效 | 权限缓存未失效 | 调用InvalidatePermissionCacheAsync() |
| 导出慢到超时 | 导出时加载了导航集合 | 用IsExport判断后跳过 |
| 删除时报外键错误 | 关联数据未清理 | 删除前查中间表/子表,或做级联 |
十、小结
关联表在 EasyAdminBlazor 里的处理套路可以总结成四句话:
- 建模:外键用
xxxId,导航用[Navigate](约定优于配置,命名不符时显式标注); - 查询:需要展示才
Include/IncludeMany,导出时跳过; - 展示:列绑外键、显示导航值、筛选用
AdminSelectEntityFilter(Generic); - 编辑:选单个关联用
AdminSelectEntity,多对多用中间表 +IncludeMany+UpdateAsync,并记得让缓存失效。
再加一条纪律:所有导航属性都放进IgnoreSearchColumns。这一条能省掉大量"无法匹配 xxx"的排查时间。
如果你正在用 .NET 10 + Blazor 做后台,关联表是绕不开的场景。EasyAdminBlazor 在实体导航、查询加载、关联选择器、多对多编辑上都给了现成做法,可以照着改。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor