Blazor Admin 关联表怎么处理?EasyAdminBlazor Navigate、Include、Join 实战

后台开发里关联表几乎是必选项:订单要显示客户名、文章要选专栏、用户要分配角色、菜单要挂父子。这篇讲 EasyAdminBlazor 里关联数据从建模到查询、展示、编辑的完整做法。


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>

三件事同时完成:

  1. 列绑定的是外键字段 ClassifyId(这样排序、筛选都作用在数据库列上);
  2. 显示的是导航属性 Classify?.ClassifyName;
  3. 列头筛选用 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 里的处理套路可以总结成四句话:

  1. 建模:外键用 xxxId,导航用 [Navigate](约定优于配置,命名不符时显式标注);
  2. 查询:需要展示才 Include / IncludeMany,导出时跳过;
  3. 展示:列绑外键、显示导航值、筛选用 AdminSelectEntityFilter(Generic);
  4. 编辑:选单个关联用 AdminSelectEntity,多对多用中间表 + IncludeMany + UpdateAsync,并记得让缓存失效。

再加一条纪律:所有导航属性都放进 IgnoreSearchColumns。这一条能省掉大量"无法匹配 xxx"的排查时间。


如果你正在用 .NET 10 + Blazor 做后台,关联表是绕不开的场景。EasyAdminBlazor 在实体导航、查询加载、关联选择器、多对多编辑上都给了现成做法,可以照着改。

相关推荐
一粒麦仔1 小时前
llama.cpp / Ollama / LM Studio:本地 LLM 推理栈的硬核拆解
人工智能·后端·架构
一勺思维1 小时前
做完半年 AI Agent 应用,我踩过的 5 个坑,全是"看起来解决了"的那种
后端
用户204937554951 小时前
Rust推理库编译失败排查:从Cargo到输入法集成
后端·程序员
yunwei371 小时前
eBPF 开发者教程: 简单的 XDP 负载均衡器
linux·后端·性能优化
考虑考虑1 小时前
SQL中的 CASE WHEN
数据库·后端·sql
IT_陈寒1 小时前
Python的列表推导式差点让我加班到凌晨
前端·人工智能·后端
掘金者阿豪1 小时前
MySQL 迁移金仓,SQL 能正常执行,为什么查询结果却不一样?
后端
机器之心1 小时前
突发!GPT-6 Sol与Claude Opus 5.5同日开打,谁是「性价比之王」
前端·人工智能·后端
用户34232323763171 小时前
Ternary-Bonsai-2-27B(PTQ1_0) 在 RTX 4090 上的部署与调优实录
后端