soulTable 后端分页 + 表头过滤

IsayIsee Lv3

背景

报表页有 4 个 tab:汇总统计 / 明细列表 / 分组统计 / 按日期查询。一开始做的是前端分页:后端一次性全量返回,table.reloadData 填数,分页、排序、过滤全在前端。

数据量小的时候没什么问题,主表到了接近 10 万行就不行了——两个明细类报表每次打开页面都要把上万行 JSON 整个传下来,首屏和翻页肉眼可见地卡。另一个日志页只有单表 COUNT,后端分页 50 条一页,一直很流畅。差别就在分页发生在数据库层还是前端。

于是把明细类报表改成后端分页(url + page: true)。soulTable 后端过滤模式的坑不少,趁这次一起记录一下。

分页模式切换(url + page: true)

layui table 后端分页的基本写法:

1
2
3
4
5
6
7
8
9
10
table.render({
elem: '#table',
url: '/api/v1/report/list',
page: true,
limit: 20,
limits: [20, 30, 50, 100],
parseData: function(res) {
return {code: res.code, msg: res.msg, count: res.count, data: res.data};
}
});
  • 翻页、排序(sort: true 的列)时 layui 自动带 page / limit / sort / order 参数请求后端
  • 后端返回 {code, count, data} 三件套,count 是总行数,用来渲染页码

第一个坑:soulTable.render(this) 必须保留。 前端分页模式里它在 reloadData 的 done 回调中调用;换成 url 模式后,要在 table.render 的 done 里补上:

1
2
3
done: function(res, curr, count) {
soulTable.render(this);
}

soulTable.export 和表头过滤都依赖这次注册。不调用的话,export 内部 deepClone(i.cols) 直接崩——导出按钮看着正常,点了没反应;表头过滤图标也不会初始化。

表头过滤:filterSos 协议与后端解析

soulTable 表头过滤(filter: true 的列)在后端分页模式下会把条件序列化成 filterSos 参数(JSON 字符串数组)随请求一起发:

1
2
3
4
5
6
7
8
[
{"id":1, "prefix":"and", "mode":"in", "field":"客户名称", "values":["客户A","客户B"]},
{"id":2, "prefix":"and", "mode":"condition", "field":"商品型号", "type":"contain", "value":"T-1"},
{"id":3, "prefix":"and", "mode":"group", "field":"商品型号", "children":[
{"mode":"condition", "prefix":"or", "field":"商品型号", "type":"eq", "value":"003"}
]},
{"id":4, "prefix":"and", "mode":"date", "field":"出库时间", "type":"specific", "value":"2026-08-25"}
]

模式(mode)全集与后端语义

mode 来源 后端处理
in 表头”数据”下拉多选 col IN (%s, ...),values 列表
condition 条件筛选(文本/数字) type ∈ eq/ne/gt/ge/lt/le/contain/notContain/start/end/null/notNull,映射 = / <> / > / >= / < / <= / LIKE %v% / LIKE v% / LIKE %v / IS NULL / IS NOT NULL
group “编辑筛选条件”生成的条件组 递归解析 children(可嵌套),组内按 prefix(and/or)拼接,整体括号包裹
date 日期筛选 type: "all" 忽略;specific(value 为具体日期)→ 区间 col >= %s AND col < DATE_ADD(%s, INTERVAL 1 DAY)(必须用区间而非等值,否则 datetime 列只会命中当天 00:00:00 一条)

安全方面:字段名一律走白名单映射(前端列名 → SQL 列),值全部参数化 %s。过滤参数是天然的 SQL 注入入口,不能直接拼。

已知边界:条件组里嵌套聚合列(GROUP BY 报表的 COUNT 列)不支持,会忽略并记日志。聚合列条件应该进 HAVING,组内条件的归属解析太麻烦,数据量不大的场景先砍了。

最大的坑:列值字典请求(硬编码 POST)

filter items 里的 'data'(表头下拉选值)在后端分页模式下有个初始化自动行为:表格渲染完,soulTable 会往表格 URL 发一次请求,带 columns 参数(filter 列清单),拉取各列的可选值列表用来渲染下拉选项。

源码确认过(tableFilter.js):

1
E.ajax({url: p, data: e, dataType: "json", method: "post", ...})  // method 硬编码 "post"

请求方式是写死的 method:"post",和表格配置的 method 无关;参数放在 form body(columns=...&width=...)。这是 soulTable 官方后端过滤模式的设计,官方 Java 支持库 layui-soul-table-java 同样要处理这个请求。

踩坑 1:前端转 GET 走不通

一开始不想让后端多一个 POST 路由,想用 $.ajaxPrefilter 把 POST 转成 GET。两个致命细节:

  1. jQuery prefilter 阶段 options.method 还没合并进 options.type——jQuery 在 prefilter 之后才执行 s.type = s.method || s.type。所以判断 options.type === 'POST' 永远是 false(type 还是默认的 GET),转换从未生效。
  2. 就算同时检查 method,soulTable 的 data 可能是字符串(查询串),$.param(字符串) 会按字符索引序列化成 0=c&1=o&2=l&3=u... 这种灾难结果。

结论:不要在前端拦截转换,后端直接支持 POST。

踩坑 2:FastAPI 同步端点完全可以接收 Form

项目有”无 async”的硬约定,最初以为读请求 body 需要 async 端点,才走了前端转换的弯路。实际上 FastAPI 的 Form 参数在同步 def 端点上完全可用——body 解析由框架在调用函数前完成,不需要 await request.form():

1
2
3
4
5
6
7
8
9
10
from fastapi import Form

@router.post("/report/list", summary="soulTable 列值字典请求")
def post_report_api(
request: Request,
columns: str = Form(None),
filterSos: str = Form(None),
page: int = Form(None),
auth: User = Depends(get_user)):
...

GET 和 POST 路由同路径共存(router.get + router.post),共享同一个处理函数,完全合法。

踩坑 3:列值字典响应不能套统一包装

后端统一响应格式是 {code, msg, count, data},但列值字典必须返回裸 JSON:

1
{"客户名称": ["客户A", "客户B"], "商品型号": ["T-100", "T-200"]}

soulTable 的 success 回调会遍历响应对象的每一个键来渲染下拉选项——{code: 0, ...} 会被当成列名处理,下拉列表直接错乱。

踩坑 4:MySQL 二进制字符集列返回 bytearray

utf8_bin / gb2312 字符集列在 mysql.connector 下返回 bytearray,jsonable_encoder 序列化会 500(dict(obj) 失败)。列值字典要显式转换:

1
2
if isinstance(val, bytearray):
val = val.decode('utf-8', errors='ignore')

踩坑 5:测试直接调用端点的 Query 默认值陷阱

测试如果用”直接调用端点函数”的方式(不经 FastAPI 路由,比如单元测试基类手工构造 request/auth 参数),columns: str = Query(None) 的默认值是 Query 对象本身(truthy)——if columns: 误判进入列值字典分支,返回空 dict。测试必须显式传 columns=None(生产环境由 FastAPI 解析默认值,没有这个问题)。

GROUP BY 报表的分页细节

“分组统计”报表是 GROUP BY 客户ID, 商品ID,分页有两个特殊点:

  1. 聚合列过滤进 HAVING:filterSos 对”数量”列的条件(比如 ge 10)不能放 WHERE——聚合列在 WHERE 里是 SQL 语法错误,要解析到 HAVING COUNT(t.id) >= %s。实现上把过滤条件按字段归属拆成 WHERE 和 HAVING 两组。
  2. 分组数 COUNT 要包派生表:SELECT COUNT(*) FROM t GROUP BY x 返回的是每组的计数(每行一个 1),不是分组总数。正确写法:
1
2
3
SELECT COUNT(*) FROM (
SELECT 1 FROM orders o JOIN customers c ... GROUP BY o.customer_id, o.item_id
) AS sub
  1. 列值字典对聚合列返回空数组(DISTINCT COUNT 表达式没有意义)。

性能:翻页慢的元凶是 COUNT

改完分页后翻页还是有明显等待感。查了一圈,问题出在 COUNT 查询——主表约 10 万行,3 表 JOIN + 无索引状态过滤全扫,每次翻页都要重算一遍。三个优化,按收益排序:

  1. 数据库加索引(ALTER TABLE orders ADD INDEX 日期列 (日期列))——日期范围过滤从全表扫变成只扫范围内的行,收益最大
  2. COUNT 短缓存(5 秒,key 含过滤条件指纹)——翻页连点时 COUNT 只算一次,条件变化自动失效
  3. COUNT 去掉不必要的 JOIN——COUNT 不选关联表列,没有对应列的过滤时去掉该 JOIN

另外,soulTable.export 默认导出的是表格实例当前持有的数据——后端分页后实例里只有当前页,导出全量要在导出事件里重新拉全量(不传 page/limit 参数),再 soulTable.export(id, {data: 全量})(soulTable 支持 data 选项覆盖导出数据),不能直接导实例数据。

检查清单

  • table.render 配 url + page: true,done 回调里必须 soulTable.render(this)
  • 后端解析 filterSos(in / condition / group 递归 / date 区间),字段白名单 + 参数化防注入
  • 后端接收排序参数 sort / order(白名单映射)
  • 列值字典请求是硬编码 POST + form body——后端加 POST 路由(Form 参数),返回裸 JSON {列名: [值]}
  • 聚合列过滤进 HAVING;分组总数 COUNT 包派生表
  • 列值 bytearray 转字符串;测试直接调用端点时显式传 None
  • COUNT 是分页性能核心——索引 + 缓存 + 去多余 JOIN
  • 导出全量需重新拉取 + soulTable.export(id, {data})

改完以后,明细页首屏从全量拉取变成 20 条一页,翻页、排序、过滤、下拉选值、导出全部在数据库层完成,基本感觉不到卡了。


参考:layui-soul-table 官方文档(https://saodiyang.gitee.io/layui-soul-table)、`tableFilter.js` 源码(node_modules 或插件目录内)

  • 标题: soulTable 后端分页 + 表头过滤
  • 作者: IsayIsee
  • 创建于 : 2026-08-26 10:36:13
  • 更新于 : 2026-08-26 10:47:47
  • 链接: https://blog.120528.xyz/2026/08/26/651d94a0/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。