news 2026/10/17 10:54:13

ABP框架集成BootstrapTable:列表页分页排序搜索实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ABP框架集成BootstrapTable:列表页分页排序搜索实战

ABP入门系列写到第14篇,终于要碰点前端“看得见摸得着”的东西了。这一篇的主题是应用BootstrapTable表格插件,把ABP后端已经写好的应用服务方法直接接到前端表格上,让列表页从“一条条手写DOM”变成“配置驱动”。如果你正在维护老版ABP(ASP.NET Boilerplate)的MVC模板项目,或者公司里还有不少基于jQuery+Bootstrap搭建的中后台页面,这篇内容大概率能直接“抄作业”。

之前十几篇聊了很多后端的事:应用服务、DTO、权限校验、自动API、工作单元、审计日志,可列表页这个后台系统里最高频的页面,一直缺一个像样的前端表格方案。ABP默认模板并没有内置表格组件,Angular和Blazor版本另当别论,但MVC+jQuery模板下,我们最常做的事就是自己拼接HTML、自己写分页、自己维护排序状态,代码越堆越多,痛点也越攒越深。后来我把BootstrapTable接进去,整个列表页的开发模式瞬间变了个样。

1. 表格选型的现实:为什么老ABP项目里BootstrapTable依然能打

1.1 这个系列走到现在,前端到底缺了什么

前面十几篇把ABP的“后端心法”过了一遍,但后台管理系统里真正每天都在用的,其实是那个最朴素的列表页:查询、翻页、排序、编辑、删除。ABP的老版MVC模板自带jQuery和Bootstrap,却一直没有提供表格组件,等于把“最后一公里”留给开发者自己解决。

没有表格插件之前,我们常用的写法是:后端返回一个List,前端用JavaScript拼<tr>,分页控件自己写一套,排序字段自己存状态,搜索条件手动拼到查询字符串里。如果只有一个页面这么干还能忍,但后台系统动辄几十个列表页,每一页都要重复这些活,维护成本会直线上升。更要命的是每个开发者拼出来的表格风格还不一样,这个页面点页码刷新,那个页面点页码弹新窗口,样式和交互完全没法统一。

1.2 为什么是BootstrapTable,而不是DataTables或LayUI

当时也对比过几个方案。DataTables功能确实更强,但对Bootstrap风格的适配没有BootstrapTable自然,默认外观一眼看上去就是“别人家的表格”;LayUI是另一套生态,和ABP默认模板的Bootstrap样式混在一起,视觉割裂感比较明显;完全手写表格则等于重新发明轮子,分页、排序、搜索这些通用能力都要自己实现。

综合考虑,在老ABP+jQuery模板这个前提下,BootstrapTable是改动成本最低、见效最快的选择。它本身就是一个jQuery插件,配置项很集中,服务端分页、客户端分页、排序、搜索、列模板、事件都覆盖到了,文档也足够丰富。更重要的是它和Bootstrap是同一套视觉语言,放进ABP默认模板几乎不需要额外调整样式。

需要说明一下,这篇内容的背景是老版ABP的MVC模板,也就是传统服务端渲染加jQuery那一条技术路线。ABP Framework新版的Razor Class Library项目也可以参考这里的思路,只是资源引用和命名空间细节略有差异。

2. 动手前的对齐:应用服务返回结构、DTO与页面引用了哪些“约定”

2.1 先把后端方法补成“可被表格调用”的形态

表格要的数据通常是一个分页列表,ABP对这类场景早就有标准契约。输入侧用PagedAndSortedResultRequestDto,输出侧用PagedResultDto<T>。如果项目里用的还是AsyncCrudAppService,会发现它已经默认实现了分页返回,但搜索条件需要我们重写CreateFilteredQuery来补。

拿一个很简单的人员管理页面举例,输入DTO这样声明:

public class GetPersonListInput : PagedAndSortedResultRequestDto { public string Filter { get; set; } }

应用服务继承AsyncCrudAppService,然后重写过滤逻辑:

[AbpAuthorize] public class PersonAppService : AsyncCrudAppService<Person, PersonDto, int, GetPersonListInput, PersonDto, PersonDto> { public PersonAppService(IRepository<Person, int> repository) : base(repository) { } protected override IQueryable<Person> CreateFilteredQuery(GetPersonListInput input) { return base.CreateFilteredQuery(input) .WhereIf(!string.IsNullOrEmpty(input.Filter), p => p.Name.Contains(input.Filter)); } protected override async Task<PagedResultDto<PersonDto>> CreatePagedResultAsync( IQueryable<Person> query, GetPersonListInput input) { var totalCount = await query.CountAsync(); var items = await query .OrderBy(input.Sorting ?? nameof(Person.CreationTime)) .PageBy(input) .ToListAsync(); return new PagedResultDto<PersonDto>( totalCount, ObjectMapper.Map<List<PersonDto>>(items) ); } }

这里有个容易忽略的细节:PagedAndSortedResultRequestDto自带了MaxResultCount、SkipCount、Sorting三个属性,ABP自动API在接收前端请求时会自动绑定这三个参数,所以前端只要把参数名对齐,后端几乎不用额外写绑定代码。

还要理解ABP的自动API机制。PersonAppService会被自动暴露成一个RESTful接口,默认地址是/api/services/app/person/getAll,同时前端也会生成一个叫abp.services.app.person的JavaScript代理。BootstrapTable可以直接请求这个URL,也可以在编辑、删除等操作里直接用abp.services.app.person.delete(id)这样的代理方法,两种方式各有适用场景,后面会展开讲。

2.2 页面资源引用的顺序有哪些讲究

BootstrapTable依赖jQuery和Bootstrap,资源引用的顺序必须严格,错了就是白屏或控制台直接报错。

引入顺序是:

  1. jQuery
  2. Bootstrap的CSS和JS
  3. bootstrap-table.min.css
  4. bootstrap-table.min.js
  5. 语言包bootstrap-table-zh-CN.min.js
  6. 页面自己的脚本

ABP的布局页_Layout.cshtml里通常已经引入了jQuery和Bootstrap,这时候新增BootstrapTable就只需要把后三个加到表单页或布局页里。如果BootstrapTable的JS被放在了jQuery之前,浏览器控制台会报类似BootstrapTable requires jQuery的错误,这个问题虽然低级,但我见过不止一次。

语言包是很多人忽略的一步。不引语言包,分页组件显示的是英文的Pagination、Total这些字样;引入中文语言包之后,分页信息、按钮提示都会变成中文,体验差别很大。

3. 前端核心对接:把ABP的result翻译成BootstrapTable需要的total和rows

3.1 表格初始化

页面里放一个空table标签就够,BootstrapTable会在初始化时动态生成表头、分页条、工具栏。我的表单页结构大概是这样:

<div class="row"> <div class="col-md-12"> <div class="ibox float-e-margins"> <div class="ibox-title"> <h5>人员列表</h5> </div> <div class="ibox-content"> <div class="row"> <div class="col-sm-3"> <input type="text" id="FilterKeyword" class="form-control" placeholder="搜索姓名" /> </div> <div class="col-sm-1"> <button id="SearchButton" class="btn btn-primary">查询</button> </div> </div> <table id="PersonTable"></table> </div> </div> </div> </div>

接着在页面脚本里初始化:

var personTable = $('#PersonTable').bootstrapTable({ url: abp.appPath + 'api/services/app/person/getAll', method: 'get', pagination: true, sidePagination: 'server', pageSize: 10, pageList: [10, 20, 50], showRefresh: true, queryParams: function (params) { return { skipCount: params.offset, maxResultCount: params.limit, sorting: params.sort ? params.sort + ' ' + params.order : 'creationTime desc', filter: $('#FilterKeyword').val() }; }, responseHandler: function (res) { if (res.success && res.result) { return { total: res.result.totalCount, rows: res.result.items }; } return { total: 0, rows: [] }; }, columns: [ [ { field: 'name', title: '姓名', sortable: true }, { field: 'email', title: '邮箱' }, { field: 'phone', title: '电话' }, { field: 'creationTime', title: '创建时间', formatter: dateTimeFormatter }, { field: 'id', title: '操作', formatter: operateFormatter, events: operateEvents } ] ] });

这段代码是整篇文章的核心,如果只记一个片段,就记responseHandler和queryParams这两块。

3.2 responseHandler为什么必不可少

ABP的自动API返回结构是外层带一个result包装的:

{ "result": { "totalCount": 100, "items": [ { "id": 1, "name": "张三", "email": "..." } ] }, "success": true, "error": null, "targetUrl": null }

但BootstrapTable在服务端分页模式下,期望的返回结构是:

{ "total": 100, "rows": [] }

两个结构对不上,所以必须在responseHandler里做翻译。刚开始接的时候我没写这一层,直接把res.result当成返回值,结果表格只显示当前页数据,总页数永远是1,翻页点不动。后来加上翻译,问题立刻消失。

这里可以多说一句:为什么ABP要包这层壳?因为它需要统一表达success、error、targetUrl这些状态信息,任何一个应用服务方法抛异常,ABP都能把错误信息封装成标准格式返回。代价就是前端对接方必须多写一层适配。理解了这一点,就不会觉得responseHandler是多余代码。

3.3 queryParams里的三个字段名是ABP的命门

BootstrapTable默认发给后端的参数是limit、offset、search、sort、order这几个,但ABP认识的是skipCount、maxResultCount、sorting。所以必须用queryParams做一次重写:

  • params.offset对应skipCount
  • params.limit对应maxResultCount
  • params.sort和params.order拼接成sorting,格式是字段名 ASC/DESC,例如name asc

ABP在模型绑定的时候对参数名大小写并不敏感,传skipcount也能绑上,但为了规范统一,前端还是用小驼峰写法。

sorting的默认值也很关键。不要留空字符串,我一般给一个creationTime desc,否则ABP在排序字段为空时可能按数据库默认顺序返回,列表顺序不稳定。

4. 翻页、排序、搜索联动:三个高频坑位的位置与解法

4.1 总条数的计算和显示问题

服务端分页模式下,总条数必须来自数据库的Count(*)结果,而不是当前页返回的数组长度。ABP的totalCount就是这个值,只要responseHandler把它放到了total字段,BootstrapTable就能正确计算总页数。

有个很容易踩的坑是:后端某个开发者图省事,直接返回一个List<T>而不是PagedResultDto<T>,前端表格就只能拿到数组本身,totalCount恒等于items.length。症状是“第一页有10条数据,但底部显示总共只有10条”,翻页完全无效。遇到这种情况不用怀疑BootstrapTable配置,先去后端看返回类型是不是PagedResultDto。

4.2 排序参数怎么拼接才不会报错

ABP的Sorting字段长这样:"name ASC"、"creationTime DESC"。而BootstrapTable传过来的params.sort是列field名,比如name,params.order是asc或者desc。如果直接拼接,sorting就变成了name asc,这个值会直接传给EF的OrderBy。

问题来了:DTO字段名是Name,前端field是name,拼出来的name asc到了EF那里找不到叫name的属性,就会抛“无法排序”的异常。一是可以在列定义里用sortName指定后端实体属性名,二是更稳妥的做法,在后端做排序字段白名单映射。

我推荐后端白名单方案:

private static readonly Dictionary<string, string> SortMap = new Dictionary<string, string> { { "name", nameof(Person.Name) }, { "creationTime", nameof(Person.CreationTime) } };

在排序方法里先解析前端传过来的排序字段,做一层翻译,翻译不出来的字段直接忽略并回退到默认值。这样即使前端传了一个后端实体里不存在的字段名,也不会抛异常。

protected override IQueryable<Person> SortQuery(IQueryable<Person> query, GetPersonListInput input) { var sorting = input.Sorting; if (string.IsNullOrWhiteSpace(sorting)) { sorting = nameof(Person.CreationTime) + " desc"; } else { var parts = sorting.Split(' '); var key = parts[0].ToLower(); if (SortMap.ContainsKey(key)) { sorting = SortMap[key] + (parts.Length > 1 ? " " + parts[1] : ""); } else { sorting = nameof(Person.CreationTime) + " desc"; } } input.Sorting = sorting; return query.OrderBy(sorting); }

4.3 搜索框和表格怎么联动

搜索联动有两种常见写法,一种是点“查询”按钮时手动调用:

$('#SearchButton').on('click', function () { $('#PersonTable').bootstrapTable('refresh'); });

因为queryParams里已经在实时读取#FilterKeyword的值,所以不需要额外传参。这是第一种写法的好处:搜索词永远来自当前输入框,刷新表格自动带上。

第二种是用BootstrapTable自带的search属性,把它设成true,表格会自己渲染一个搜索框。但在服务端分页模式下这个自带的搜索框默认只做前端过滤,不是发到后端的,所以实际意义不大。我更建议关掉内置search,自己放一个输入框配合queryParams读值,逻辑更直白。

如果要做输入即搜,也就是每敲一个字就刷新,那一定记得做防抖:

var searchTimer = null; $('#FilterKeyword').on('keyup', function () { clearTimeout(searchTimer); searchTimer = setTimeout(function () { $('#PersonTable').bootstrapTable('refresh'); }, 300); });

不加防抖,每敲一个字母就发一次请求,后端瞬间就会被几十个查询淹没,页面也会卡顿。

5. 操作列闭环:编辑弹窗、删除确认与表格区域的安全刷新

5.1 操作列渲染

列表页最后一列通常是操作按钮,BootstrapTable用formatter来渲染这一列:

function operateFormatter(value, row, index) { return [ '<button type="button" class="btn btn-xs btn-primary edit-person" ', 'data-id="' + row.id + '">编辑</button>', '<button type="button" class="btn btn-xs btn-danger delete-person" ', 'data-id="' + row.id + '">删除</button>' ].join(' '); }

注意formatter里返回的是HTML字符串,按钮上的>$('body').on('click', '.edit-person', function () { var id = $(this).data('id'); editPerson(id); }); $('body').on('click', '.delete-person', function () { var id = $(this).data('id'); deletePerson(id); });

这样无论表格翻到第几页、刷新多少次、行怎么重建,事件都依然有效。因为事件绑定在祖先节点body上,借助事件冒泡捕获子元素点击,天然适合表格这种DOM不断重建的场景。

5.3 删除确认与ABP消息弹窗的配合

删除操作不能直接调接口,必须给用户一个确认机会。ABP老版自带abp.message.confirm,用法很顺手:

function deletePerson(id) { abp.message.confirm('确定要删除这条记录吗?', '提示', function (isConfirmed) { if (!isConfirmed) { return; } abp.services.app.person.delete(id).done(function () { abp.notify.success('删除成功'); $('#PersonTable').bootstrapTable('refresh'); }).fail(function (error) { abp.notify.error(error.message || '删除失败'); }); }); }

这里有两个细节。第一,abp.message.confirm的回调参数isConfirmed,点击确认是true,取消是false,不要写反。第二,删除成功后调用bootstrapTable('refresh'),因为queryParams会重新读取当前搜索框、页码和排序状态,表格会自动回到当前页,体验比较自然。

5.4 refresh和load的差别

很多人在刷新表格时纠结refresh和load的区别。简单说:

  • refresh:用当前表格已有的queryParams重新发请求,常用于查询条件变化、删除、编辑后刷新当前列表。
  • load:直接给表格塞一份新数据,同时重置分页状态,常用于外部跳转带入的全新数据集。

在编辑、删除这类场景下,应该用refresh而不是load,因为refresh会保留当前页码和排序,用户编辑完第3页的一行数据,刷新后还停在那个位置附近,不会被拽回第1页。

6. 细节打磨:日期格式、请求参数瘦身与表格性能取舍

6.1 日期时间的显示问题

ABP的CreationTime之类的DateTime属性,经过JSON序列化后通常是一串ISO字符串,比如2025-01-20T10:30:00Z,直接显示在表格里又长又难读。处理方式是加一个列格式化函数:

function dateTimeFormatter(value) { if (!value) { return '-'; } var d = new Date(value); if (isNaN(d.getTime())) { return value; } var pad = function (n) { return n < 10 ? '0' + n : '' + n; }; return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); }

列定义里加上:

{ field: 'creationTime', title: '创建时间', formatter: dateTimeFormatter }

这里有个关于时区的点值得留意。如果ABP返回的时间字符串以Z结尾,浏览器会按本地时区解析,展示出来的时间和数据库里的UTC时间会有时差。如果后端统一存UTC,前端展示时按本地时间换算其实问题不大;但如果你发现表格时间比预期早了8小时,就要去检查后端序列化是不是强制转成了UTC,以及在显示时是否需要按固定时区格式化。

6.2 请求参数瘦身与防抖已经做了,还能优化什么

接BootstrapTable之后,最影响性能的往往是请求参数太肥。有些人图方便在queryParams里把一整个表单序列化塞进去,后端一收到就是一堆用不上的字段。建议只传接口真正需要的那几项:skipCount、maxResultCount、sorting、filter,其他参数一概不传。

在pageList里控制好每页条数,默认pageSize设成10或20就够了,不要一上来就传100条,后台列表页大多数时候用户只用得到前几页。

如果表格里还有金额、状态这类字段,也可以统一用formatter做枚举值翻译和后端状态码映射,让表格显示的是“已启用/停用”而不是0/1,这样可读性会提高不少。

6.3 关于BootstrapTable和ABP自动API代理的混用体会

最后分享一下我实际用下来的一套组合拳。表格初始化时的url指向ABP自动API的GET接口,让BootstrapTable自己负责分页参数和请求发送;但编辑、删除、批量操作这些动作,我不走表格的url,而是直接用abp.services.app.person代理调用。原因很简单:abp.services代理自带ABP的错误处理、请求头、防伪造令牌注入,比手动$.ajax去拼URL省心得多,而且不用自己去管ABP封装结构。

这两套方式在公司内部项目里已经稳定跑了大半年,中间只因为EF Core版本升级踩过一次排序字段的坑,其余时间几乎没为表格本身再操过心。如果你也打算在ABP项目里引入BootstrapTable,我建议先把responseHandler和queryParams这两段配置想清楚,再往后端补充排序白名单和分页契约,整个页面链路基本就通了。至于更花哨的树形表格、可见列切换、导出Excel,等基础版本跑起来之后再按需加,不会影响现有结构。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 10:53:52

SNL编译器源码解析:从词法分析到四元式生成的避坑指南

简介&#xff1a;一份面向编译原理课程设计与实验的SNL语言编译器完整源码&#xff0c;适合计算机专业学生、教师及对编译器实现感兴趣的开发者&#xff1b;代码基于C/C编写&#xff0c;覆盖词法分析、语法分析、语义分析等核心阶段&#xff0c;可完成SNL语言编译流程&#xff…

作者头像 李华
网站建设 2026/10/17 10:53:18

四元数:游戏旋转的幕后高手

一句话&#xff1a;四元数用 4 个数记录"绕哪根轴、转多少度"&#xff0c;把一次旋转当成一个整体来存。 它不会死锁、插值平滑、计算便宜&#xff0c;所以引擎内部几乎所有旋转都用它。Unity 的 transform.rotation 本身就是四元数。延续上一篇&#xff1a;先讲它是…

作者头像 李华
网站建设 2026/10/17 10:52:24

Ubuntu手动安装内核:从deb包到源码编译的完整实践指南

1. 项目概述&#xff1a;为什么“手动安装内核”不是折腾&#xff0c;而是系统掌控力的分水岭 在Ubuntu生态里&#xff0c;“升级内核”这件事&#xff0c;绝大多数人默认点开“软件更新”就完事——系统自动推送、自动下载、自动安装、自动更新GRUB菜单&#xff0c;整个过程安…

作者头像 李华
网站建设 2026/10/17 10:51:53

Spring Boot多数据源配置:MyBatis双库集成与避坑指南

简介&#xff1a;这份PDF资料面向使用SpringBoot与MyBatis的Java后端开发者&#xff0c;聚焦多数据源配置这一常见痛点&#xff0c;尤其适合需要处理主从模式或分库业务、又不想引入JPA或AOP动态切换等复杂方案的中级开发者。资源为单个PDF文件&#xff0c;压缩包约54KB&#x…

作者头像 李华
网站建设 2026/10/17 10:51:01

FileZilla Server 0.9.39汉化绿色版:免安装免注册表的便携FTP服务器

简介&#xff1a;本资源为FileZilla Server 0.9.39汉化绿色版&#xff0c;面向Windows系统管理员、Web开发者及FTP服务初学者&#xff0c;提供开箱即用的轻量级FTP服务器部署方案&#xff0c;无需安装、不写注册表&#xff0c;适用于局域网文件共享、本地测试环境搭建及教学演示…

作者头像 李华
网站建设 2026/10/17 10:49:10

AI编程沙箱只读模式两次被击穿:执行环境分层隔离方案

先交代一下背景吧。这个标题说的是我自己团队最近遇到的一件事——我们的 AI 编程沙箱&#xff0c;在两个月内被"击穿"了两次&#xff0c;而且第二次是在已经开启了"最严只读模式"的情况下发生的。所谓"最严只读"&#xff0c;就是文件系统层面做…

作者头像 李华