后台开发里关联表几乎是必选项:订单要显示客户名、文章要选专栏、用户要分配角色、菜单要挂父子。这篇讲 EasyAdminBlazor 里关联数据从建模到查询、展示、编辑的完整做法。
一、四种关联,四种 Navigate 写法
FreeSql 用 [Navigate] 描述关联,框架的实体已经给出了四类范例。
1. 多对一(最常用)
文章属于一个专栏:
csharp
[Table(Name = "blog_article")]
public partial class Article : ApprovalEntityFull
{
/// <summary>随笔专栏</summary>
[DisplayName("随笔专栏")]
[Required]
public long? ClassifyId { get; set; }
public Classify Classify { get; set; } = default!;
}
这里 ClassifyId 是外键列,Classify 是导航属性。注意仓库里的示例实体并没有在 Classify 上写 [Navigate(nameof(ClassifyId))]------FreeSql 会按命名约定自动识别 ClassifyId 与 Classify 的关系。如果你的命名不符合约定,就显式标注:
csharp
[Navigate(nameof(ClassifyId))]
public Classify Classify { get; set; } = default!;
2. 一对多(反向导航)
csharp
partial class SysUser
{
[Navigate(nameof(SysRoleUser.UserId))]
[JsonIgnore]
public List<SysRoleUser> RoleUsers { get; set; } = [];
}
3. 多对多
csharp
public class SysRole : Entity
{
[JsonIgnore]
[Navigate(ManyToMany = typeof(SysRoleUser))]
public List<SysUser> Users { get; set; } = [];
[JsonIgnore]
[Navigate(ManyToMany = typeof(SysRoleMenu))]
public List<SysMenu> Menus { get; set; } = [];
}
中间表是显式定义的实体:
csharp
public class SysRoleMenu
{
public long RoleId { get; set; }
public long MenuId { get; set; }
public SysRole Role { get; set; } = default!;
public SysMenu Menu { get; set; } = default!;
}
4. 自关联(树形)
csharp
public partial class SysMenu : EntityCreated, IHasParentId<long>
{
[Navigate(nameof(ParentId))]
[JsonIgnore]
public SysMenu? Parent { get; set; }
[Navigate(nameof(ParentId))]
[JsonIgnore]
public List<SysMenu> Childs { get; set; } = [];
public long ParentId { get; set; }
}
注意导航属性上的 [JsonIgnore]:这些是 ORM 层面的对象图,序列化给前端时容易产生循环引用或体积膨胀,所以框架统一加了忽略。
二、查询:Include 与 IncludeMany
1. 多对一用 Include
csharp
private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
e.Select.Include(a => a.Classify);
}
2. 多对多用 IncludeMany
角色页需要把"这个角色有哪些菜单"一起查出来(因为点击行时要回显勾选状态):
csharp
private void OnBeforeQuery(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 取导航属性:
razor
<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:
razor
<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
razor
<AdminSelectEntity TItem="Classify" TKey="long?"
@bind-Value="Model.ClassifyId"
GetText="e => e.ClassifyName"
ShowSearch />
参数(源码):
csharp
[Parameter] public Expression<Func<TItem, bool>>? Where { get; set; }
[Parameter] public Func<TItem, string> GetText { get; set; } = x => x?.ToString() ?? string.Empty;
[Parameter] public Func<TItem, string>? GetValue { get; set; }
[Parameter] public TimeSpan CacheDuration { get; set; } = TimeSpan.FromSeconds(30);
注意 CacheDuration 默认是 30 秒。
2. AdminSelectTable:绑整个实体
需要把选中的实体对象也带回来(比如要读它的多个字段)时用这个:
razor
<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 一致:
razor
<AdminSelectEntity TItem="SysUser" TKey="long"
@bind-ValueId="Model.AuditorId"
GetText="u => u.Nickname"
UseDataPermission="true" />
打开之后,"能选谁"就受当前用户的数据权限约束,避免选到一个自己根本无权查看的人。
五、多对多编辑:角色分配菜单的真实实现
Pages/Role.razor 是一个完整的多对多编辑范例:左边角色表格,右边菜单树。
1. 点击行,回显已选
csharp
private async Task OnClickRowCallback(SysRole row)
{
select = row;
var roleMenuIds = row.Menus.Select(m => m.Id).ToList();
menuSelectionTree?.UpdateTreeSelection(roleMenuIds);
await InvokeAsync(StateHasChanged);
}
row.Menus 之所以有值,是因为查询时 IncludeMany(x => x.Menus) 已经加载了。
2. 保存,更新关联表
csharp
[AdminButton("alloc_menus")]
[OperationLog("修改角色菜单权限")]
private async Task OnSaveMenu()
{
if (select != null)
{
if (select.Menus == null || select.Menus.Count == 0)
{
await SwalService.Warning(CommonLocalizer["请至少选择一个菜单权限"]);
return;
}
await _repo.UpdateAsync(select);
await admin.InvalidatePermissionCacheAsync();
await ToastService.Success(CommonLocalizer["保存数据"], CommonLocalizer["权限保存成功"]);
}
}
三个关键点:
[AdminButton("alloc_menus")]:方法级权限,没有这个按钮权限直接拦截;[OperationLog("修改角色菜单权限")]:审计留痕;InvalidatePermissionCacheAsync():角色-菜单关系变了,权限缓存必须立即失效,否则用户要等 30 分钟才生效。
3. 删除前的引用检查
删除角色前要确认没人用它:
csharp
var roleIds = e.Items.Select(x => x.Id).ToList();
var usedCount = await _repo.Orm.Select<SysRoleUser>()
.Where(x => roleIds.Contains(x.RoleId))
.CountAsync();
if (usedCount > 0)
{
await SwalService.Error(CommonLocalizer["该角色已分配给用户,不能删除"]);
e.Cancel = true;
}
这是关联表最典型的"引用完整性"处理:先查中间表,有人用就拦住删除。
六、IgnoreSearchColumns:导航属性不能当筛选列
这是关联表最容易踩的坑。页面里只要有导航集合,就要在 AdminTable 上声明忽略:
razor
<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 组件插槽。
如果需要"一张订单下面有多条明细",可行的做法是:
razor
<!-- 订单编辑模板里嵌一个绑定当前订单的明细表格 -->
<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 在实体导航、查询加载、关联选择器、多对多编辑上都给了现成做法,可以照着改。