Blazor Admin 高级查询实战:条件筛选、动态查询与关联数据

Original 2026-09-23 778 views

上一篇讲了 AdminTable 的整体结构,这一篇专门讲"查询条件"这一块:从界面上的搜索框、列头筛选、高级搜索,到最终生成的 SQL,中间发生了什么。


一、四种查询入口

AdminTable 支持四类筛选,BootstrapBlazor 负责收集,框架负责翻译成 SQL:

入口 开启方式 收集到哪
模糊搜索 ShowSearch + 列上 Searchable="true" QueryPageOptions.Searches
高级搜索 ShowAdvancedSearch QueryPageOptions.AdvanceSearches
列头筛选 列上 Filterable="true" QueryPageOptions.Filters
自定义搜索 页面调用 QueryAsync 时传 CustomerSearches QueryPageOptions.CustomerSearches

排序信息在 SortList / SortName + SortOrder 里。


二、翻译层:QueryPageOptions → DynamicFilterInfo

public static DynamicFilterInfo ToDynamicFilter(this QueryPageOptions option, params string[] ignoreColumns)
{
    var ret = new DynamicFilterInfo() { Filters = [] };

    // 处理模糊搜索
    if (option.Searches.Count > 0)
    {
        var filters = option.Searches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null).ToList();
        if (filters.Count > 0)
        {
            ret.Filters.Add(new()
            {
                Logic = DynamicFilterLogic.Or,
                Filters = filters
            });
        }
    }

    // 处理自定义搜索
    if (option.CustomerSearches.Count > 0)
    {
        ret.Filters.AddRange(option.CustomerSearches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
    }

    // 处理高级搜索
    if (option.AdvanceSearches.Count > 0)
    {
        ret.Filters.AddRange(option.AdvanceSearches.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
    }

    // 处理表格过滤条件
    if (option.Filters.Count > 0)
    {
        ret.Filters.AddRange(option.Filters.Select(i => i.ToDynamicFilter(ignoreColumns)).Where(i => i != null));
    }

    ret.Filters = ret.Filters.Where(i => i != null).ToList();

    return ret;
}

这里有一个关键语义差异:

  • 模糊搜索是"或":Searches 之间用 DynamicFilterLogic.Or 组合。多个可搜索列之间是"任一匹配",符合搜索框的直觉。
  • 高级搜索和列头筛选是追加条件:和外层条件是"与"的关系。

也就是说:

(搜索框:列A 匹配 OR 列B 匹配)
AND (高级搜索条件)
AND (列头筛选条件)

操作符映射

private static DynamicFilterOperator ToDynamicFilterOperator(this FilterAction action) => action switch
{
    FilterAction.Equal => DynamicFilterOperator.Equal,
    FilterAction.NotEqual => DynamicFilterOperator.NotEqual,
    FilterAction.Contains => DynamicFilterOperator.Contains,
    FilterAction.NotContains => DynamicFilterOperator.NotContains,
    FilterAction.GreaterThan => DynamicFilterOperator.GreaterThan,
    FilterAction.GreaterThanOrEqual => DynamicFilterOperator.GreaterThanOrEqual,
    FilterAction.LessThan => DynamicFilterOperator.LessThan,
    FilterAction.LessThanOrEqual => DynamicFilterOperator.LessThanOrEqual,
    _ => throw new NotSupportedException()
};

界面上的 FilterAction 就这样一一映射到 FreeSql 的查询操作符,不需要你手写 Where。


三、ignoreColumns:为什么必须有

private void GetIgnoredPropertyNames()
{
    if (IgnoreSearchColumns != null && _IgnoreSearchColumns.Count == 0)
    {
        IgnoreSearchColumns.ExtractPropertyNames(_IgnoreSearchColumns);
    }
}

页面这样声明要忽略的列:

<AdminTable TItem="SysUser" TKey="long"
            IgnoreSearchColumns="x => new { x.Roles, x.Org }"
            ... />

为什么必须忽略导航属性?因为导航集合不是数据库列。如果 Roles 被当作筛选字段传给 FreeSql,会直接抛"无法匹配 xxx 属性"的错误。

注意这段逻辑的时序——忽略列表必须在任何 await 之前提取完,否则首次渲染抢跑查询时会传空列表,异常就出现了。这也是第 04 篇里反复强调"回调要在 await 前赋值"的同类问题。


四、Flags 枚举:位运算筛选

// 遍历 DynamicFilterInfo,发现字段是 Flags Enum 的节点时,使用 bitwise where 并在返回的树中移除该节点
private static DynamicFilterInfo ProcessFlagsFilters<T>(ISelect<T> select, Type entityType, DynamicFilterInfo root)
{
    ...
    if (enumType.IsEnum && Attribute.IsDefined(enumType, typeof(FlagsAttribute)))
    {
        if (node.Value != null)
        {
            try
            {
                var val = Convert.ToInt64(node.Value);
                // 使用位运算判断是否包含该位
                if (val > 0) select.Where($"(a.{columnName} & {val}) != 0");
                return null; // 从树中移除该节点,避免 WhereDynamicFilter 重复处理
            }
            catch { }
        }
    }
    ...
}

实测意义:一个 [Flags] 的"文章类型"字段,值是"原创 + 转载",筛选"原创"时用等值比较是筛不出来的,必须用位运算。这段逻辑会自动处理,只对标记了 [Flags] 的枚举生效。


五、排序:不要覆盖页面写的默认排序

public static ISelect<T> ApplyOrder<T, TKey>(this ISelect<T> select, QueryPageOptions options) where T : class, IEntity<TKey>
{
    // 优先使用 QueryPageOptions 中的排序配置(SortList 或 SortName),
    // 如果两者都不存在,则保留传入的 select 原有 OrderBy(不覆盖)。
    var hasSortList = options?.SortList != null && options.SortList.Count > 0;
    var hasSortName = options != null && options.SortOrder != SortOrder.Unset && !string.IsNullOrEmpty(options.SortName);

    if (hasSortList)
    {
        return select
                    .OrderBy(string.Join(",", options.SortList.ToArray()))
                    .OrderByPropertyNameIf(hasSortName, options.SortName, options.SortOrder == SortOrder.Asc);
    }

    if (hasSortName)
    {
        return select.OrderByPropertyNameIf(true, options.SortName, options.SortOrder == SortOrder.Asc);
    }

    // 无排序配置,直接返回传入的 select(保留其可能已设置的 OrderBy)
    return select;
}

所以页面里可以放心写默认排序:

private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
    e.Select.OrderByDescending(a => a.PublishTime);   // 用户没点排序时生效
}

用户点了列头排序后,SortList / SortName 会接管,不会出现"用户点了排序但结果没变"的问题。


六、分页与计数在同一条件上

public static async Task<QueryData<T>> GetPagedAsync<T, TKey>(this ISelect<T> select, QueryPageOptions options, params string[] ignoreColumns)
    where T : class, IEntity<TKey>
{
    var dynamicFilter = options.ToDynamicFilter(ignoreColumns);
    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 };
}

Count(out var count) 和分页查的是同一个 ISelect,条件完全一致。这是"总条数"和"当前页数据"不打架的前提。


七、页面里怎么加自己的条件:OnBeforeQuery

OnBeforeQuery 拿到的是 ISelect<TItem> 和 QueryPageOptions,可以自由扩展:

private void OnBeforeQuery(AdminQueryEventArgs<Article> e)
{
    // 1) 追加固定条件
    e.Select.Where(a => a.IsAudit == true);

    // 2) 加载关联数据(详情列展示用)
    e.Select.Include(a => a.Classify);
}

AdminQueryEventArgs<TItem> 的定义很简洁:

public record AdminQueryEventArgs<TItem>(ISelect<TItem> Select, QueryPageOptions options)
{
    /// <summary>
    /// 是否导出场景。导出全量数据时应跳过 Include/IncludeMany 导航集合加载,避免上千行时极慢。
    /// </summary>
    public bool IsExport { get; set; }
}

那句注释是实战经验:导出上千行时带上 Include 会非常慢,所以框架在导出时会传 IsExport = true,页面据此跳过导航加载。


八、列头筛选:自定义筛选器

普通列只需要 Filterable="true",框架会用默认的筛选界面。需要"选实体""选多值"这类复杂筛选时,用 FilterTemplate + FilterProvider:

<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>

AdminSelectEntityFilter 的参数(源码):

[Parameter] public Expression<Func<TItem, bool>>? Where { get; set; }
[Parameter] public Func<TItem, string> GetText { get; set; } = default!;

关联字段的模糊搜索

想按"关联实体的名称"模糊搜索,用 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>

它的参数(源码):

[Parameter] public Expression<Func<TItem, bool>>? Where { get; set; }
[Parameter] public Func<TItem, string> GetText { get; set; } = default!;
[Parameter] public Func<TItem, string>? GetValue { get; set; }
[Parameter] public FilterAction FilterAction { get; set; } = FilterAction.Equal;

注意 TKey 是"生成筛选值的类型"(这里是字符串),GetValue 决定筛选条件里放什么值,FilterAction 决定操作符(Contains 就是模糊匹配)。

多值筛选

<TableColumn @bind-Field="context.ArticleType" ComponentType="typeof(MultiSelect<ArticleType>)" Filterable="true" Searchable="true">
    <FilterTemplate>
        <FilterProvider ShowMoreButton="false">
            <AdminMultiSelectFilter TValue="ArticleType" />
        </FilterProvider>
    </FilterTemplate>
</TableColumn>

AdminMultiSelectFilter 的参数:

[Parameter] public IEnumerable<SelectedItem>? Items { get; set; }

九、性能:几个容易被忽略的点

  1. 导航集合必须放进 IgnoreSearchColumns,同时避免在查询里无条件 Include。
  2. 导出时让框架走投影。OnExportAllAsync 会按可见列做动态投影;但如果你在 OnBeforeQuery 里强制 Include 且不判断 IsExport,投影优化会被破坏。
  3. 模糊搜索的列要有索引。Contains 生成的 LIKE '%x%' 通常用不上索引,列多、数据大时要权衡哪些列可以被搜索。
  4. Count 也是查询。超大表上分页的总数统计本身有成本,必要时改成"不显示总数"或维护统计表。
  5. 排序字段加索引。默认按主键排序没问题,按业务字段排序要考虑索引。
  6. WhereDynamicFilter 拼接的条件来自界面。字段名来自列的 Field,所以不要在列上绑定非数据库属性(比如 ApprovalStatusText 这种 IsIgnore 的属性),否则会生成非法 SQL。

十、常见问题

现象 原因 处理
报"无法匹配 xxx" 导航属性被当成筛选字段 加入 IgnoreSearchColumns
搜索框只匹配了一列 只有部分列标了 Searchable="true" 需要参与模糊搜索的列都标上
用户点了排序没变化 排序字段不在可见列里,或字段名不对 检查 TableColumn 的 Field
Flags 枚举筛选结果为空 该枚举没标 [Flags] 加上 [Flags]
导出很慢 Include 在导出时也在执行 用 IsExport 判断后跳过
高级搜索条件没生效 列没标 Filterable,或字段是 IsIgnore 属性 检查列定义

十一、小结

一次查询的完整链路:

BootstrapBlazor 表格收集条件(Searches / AdvanceSearches / Filters)
   → AdminTable.OnQueryDataAsync
   → OnBeforeQuery(页面追加 Where / Include / Join)
   → ApplyDataPermission(数据权限)
   → ToDynamicFilter(翻译成 DynamicFilterInfo,Searches 用 Or)
   → ProcessFlagsFilters(Flags 枚举转位运算)
   → ApplyOrder(SortList / SortName,保留默认排序)
   → Count + Page

理解了这条链路,你就能准确判断"这个条件为什么没生效":是界面没收集到、被忽略列表挡掉了、还是 SQL 条件被合并成了别的关系。


如果你正在用 .NET 10 + Blazor 做后台,查询筛选是每天都要碰的东西。EasyAdminBlazor 把条件收集、SQL 翻译、排序分页串成了一条清晰链路,也允许在 OnBeforeQuery 里自由扩展。