分页排序
这篇解决什么
列表接口要带页码、每页条数,有时还要按列排序;导出则要一次拉全量。读完能给新模块写 XxxPageReqVO、在 Mapper 里调 selectPage,并让 VbenVxeGrid 对上 list / total。
条件怎么拼见 MyBatis 数据库。pageNo 校验见 参数校验、时间传参。PageResult 转 RespVO 见 VO 对象转换、数据翻译。
默认端口 48080,管理端前缀 /admin-api。范文走系统管理的租户列表。
组件位置
| 名称 | 说明 | 仓库路径 |
|---|---|---|
PageParam | 页码、每页条数,默认 1 / 10 | ruoyi-office/yudao-framework/yudao-common/.../pojo/PageParam.java |
PageResult | 出参:list + total | .../pojo/PageResult.java |
SortablePageParam | PageParam 加 sortingFields | .../pojo/SortablePageParam.java |
SortingField | 一列:field + order(asc / desc) | .../pojo/SortingField.java |
BaseMapperX.selectPage | IPage 转 PageResult;-1 查全部 | yudao-spring-boot-starter-mybatis/.../BaseMapperX.java |
MyBatisUtils.buildPage | 组 Page,并校验排序列名 | .../mybatis/core/util/MyBatisUtils.java |
PageUtils | 内存分页、默认排序列 | yudao-common/.../util/object/PageUtils.java |
| 分页插件 | PaginationInnerInterceptor | YudaoMybatisAutoConfiguration |
| 范文 | 租户分页 / 导出 | yudao-module-system 的 TenantController、TenantMapper |
| 前端表格 | useVbenVxeGrid,读 list / total | ruoyi-office-vben/apps/web-antd/src/adapter/vxe-table.ts |
YudaoMybatisAutoConfiguration 把分页插件挂进 MybatisPlusInterceptor。列表 SQL 不用自己写 LIMIT。
请求怎么落到表
Controller 只收 VO。Service 转发。Mapper 调 selectPage,再进表。
示意图:管理端租户列表分页落到 TenantMapper,再进 system_tenant。
接口是 GET /admin-api/system/tenant/page。权限标识 system:tenant:query(当前方法上的 @PreAuthorize 被注释掉了,新接口不要照抄这一行)。
@GetMapping("/page")
@Operation(summary = "获得租户分页")
public CommonResult<PageResult<TenantRespVO>> getTenantPage(@Valid TenantPageReqVO pageVO) {
PageResult<TenantDO> pageResult = tenantService.getTenantPage(pageVO);
return success(BeanUtils.toBean(pageResult, TenantRespVO.class));
}TenantServiceImpl.getTenantPage 直接 return tenantMapper.selectPage(pageReqVO)。
分页参数
列表查询 VO 继承 PageParam,再写搜索字段。
@Schema(description="分页参数")
@Data
public class PageParam implements Serializable {
public static final Integer PAGE_SIZE_NONE = -1;
@NotNull(message = "页码不能为空")
@Min(value = 1, message = "页码最小值为 1")
private Integer pageNo = 1;
@NotNull(message = "每页条数不能为空")
@Min(value = 1, message = "每页条数最小值为 1")
@Max(value = 200, message = "每页条数最大值为 200")
private Integer pageSize = 10;
}TenantPageReqVO 继承它,加上名称、联系人、状态、创建时间区间:
@Schema(description = "管理后台 - 租户分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
public class TenantPageReqVO extends PageParam {
private String name;
private String contactName;
private String contactMobile;
private Integer status;
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}完整字段和 @Schema 在 yudao-module-system-server/.../vo/tenant/TenantPageReqVO.java。区间绑定用 @DateTimeFormat,不要写成 @JsonFormat。
pageSize 上限是 200
校验在入参阶段就拦住。前端把每页条数开到 500 会直接 400。要一次拉全量,走下面的 PAGE_SIZE_NONE,不要改 @Max。
分页结果
PageResult 只有两列。BeanUtils.toBean 能整页转换元素类型。
@Schema(description = "分页结果")
@Data
public final class PageResult<T> implements Serializable {
private Long total;
private List<T> list;
public static <T> PageResult<T> empty() {
return new PageResult<>(0L);
}
}空页用 PageResult.empty()。响应仍包在 CommonResult 里:data.list、data.total。前端请求层会拆掉外层 code / msg。
内存里已经有完整 List 时,用 PageUtils.buildPageResult(list, pageParam) 切片,不要再打库。pageSize == PAGE_SIZE_NONE 时它直接返回全量。
Mapper 查询
Mapper 继承 BaseMapperX<T>。条件用 LambdaQueryWrapperX 的 xxxIfPresent,空值不进 SQL。
@Mapper
public interface TenantMapper extends BaseMapperX<TenantDO> {
default PageResult<TenantDO> selectPage(TenantPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<TenantDO>()
.likeIfPresent(TenantDO::getName, reqVO.getName())
.likeIfPresent(TenantDO::getContactName, reqVO.getContactName())
.likeIfPresent(TenantDO::getContactMobile, reqVO.getContactMobile())
.eqIfPresent(TenantDO::getStatus, reqVO.getStatus())
.betweenIfPresent(TenantDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(TenantDO::getId));
}
}selectPage 内部两路:
| 名称 | 说明 | 仓库路径 |
|---|---|---|
| 普通分页 | MyBatisUtils.buildPage 得到 IPage,插件补 LIMIT 和 COUNT | BaseMapperX.selectPage |
PAGE_SIZE_NONE | selectList 全量,total = list.size() | 同上 |
| 联表分页 | selectJoinPage,结果可投影到别的 class | CrmBusinessMapper、WmsItemSkuMapper |
示意图:IPage 在 BaseMapperX 里收成 PageResult。
固定排序写在 Wrapper,例如 .orderByDesc(TenantDO::getId)。联表、xxxIfPresent 细节见 MyBatis 数据库。
导出不分页
导出复用同一套 PageReqVO 和 selectPage。校验通过后再改 pageSize。
@GetMapping("/export-excel")
@Operation(summary = "导出租户 Excel")
@PreAuthorize("@ss.hasPermission('system:tenant:export')")
public void exportTenantExcel(@Valid TenantPageReqVO exportReqVO,
HttpServletResponse response) throws IOException {
exportReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
List<TenantDO> list = tenantService.getTenantPage(exportReqVO).getList();
ExcelUtils.write(response, "租户.xls", "数据", TenantRespVO.class,
BeanUtils.toBean(list, TenantRespVO.class));
}用户导出、角色导出也是这个写法。前端 exportTenant 只带搜索条件,不传 -1。
不要让浏览器传 pageSize = -1
@Min(1) 会先挡掉。-1 只允许在 Service / Controller 里、@Valid 之后赋值。
导出也要带搜索条件
setPageSize 只关掉分页,name、status 这些条件仍在。表格「导出」应对齐当前筛选项。
动态排序
表头点列排序时,PageReqVO 改继承 SortablePageParam,或接口再收一个 SortablePageParam。
@Schema(description = "可排序的分页参数")
@Data
@EqualsAndHashCode(callSuper = true)
public class SortablePageParam extends PageParam {
@Schema(description = "排序字段")
private List<SortingField> sortingFields;
}SortingField.ORDER_ASC 是 "asc",ORDER_DESC 是 "desc"。BaseMapperX.selectPage(sortablePageParam, wrapper) 会把 sortingFields 交给 MyBatisUtils.buildPage。
商品排行把查询条件和分页拆成两个入参,缺省按浏览量倒序:
@GetMapping("/rank-page")
@Operation(summary = "获得商品统计排行榜分页(商品维度)")
public CommonResult<PageResult<ProductStatisticsRespVO>> getProductStatisticsRankPage(
@Valid ProductStatisticsReqVO reqVO,
@Valid SortablePageParam pageParam) {
PageResult<ProductStatisticsDO> pageResult =
productStatisticsService.getProductStatisticsRankPage(reqVO, pageParam);
// ...
}ProductStatisticsServiceImpl 里先 PageUtils.buildDefaultSortingField(pageParam, ProductStatisticsDO::getBrowseCount),前端没传排序时补默认列。
MyBatisUtils.buildSafeOrderColumn 会把驼峰改成下划线,并用 ^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)*$ 过滤。name desc、name;drop 这类值会被丢掉,不会拼进 ORDER BY。
排序列不要手写拼接
只走 SortingField + MyBatisUtils。不要把前端传来的字符串直接 last("ORDER BY " + field)。LambdaQueryWrapper 在 PAGE_SIZE_NONE 时用 last("ORDER BY …"),同一条 Wrapper 上不要再写别的 last。
前端表格
PC 管理端列表用 useVbenVxeGrid。全局已经约定响应字段:
| 名称 | 说明 | 仓库路径 |
|---|---|---|
proxyConfig.response | result = list,total = total | apps/web-antd/src/adapter/vxe-table.ts |
PageParam / PageResult | pageNo / pageSize,list / total | packages/effects/request/.../types.ts |
| 租户 API | GET /system/tenant/page | apps/web-antd/src/api/system/tenant/index.ts |
| 租户页 | 搜索表单 + 表格 | apps/web-antd/src/views/system/tenant/index.vue |
query 里把 vxe 的 currentPage / pageSize 改成后端字段名:
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: {
schema: useGridFormSchema(),
},
gridOptions: {
columns: useGridColumns(),
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getTenantPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
} as VxeTableGridOptions<SystemTenantApi.Tenant>,
});export function getTenantPage(params: PageParam) {
return requestClient.get<PageResult<SystemTenantApi.Tenant>>(
'/system/tenant/page',
{ params },
);
}需要表头远程排序时,打开 proxyConfig.sort,并展开 buildSortingField(sorts):
query: async ({ page, sorts }) => {
return await getProductStatisticsRankPage({
pageNo: page.currentPage,
pageSize: page.pageSize,
...buildSortingField(sorts),
});
},buildSortingField 在 @vben/request,会生成 sortingFields[0].field、sortingFields[0].order,供 GET 绑定到 List<SortingField>。商品排行页在 views/mall/statistics/product/modules/rank-card.vue。
搜索后回到第一页
vxe 点搜索会重新 query。自己写按钮时先把页码重置再拉数,避免停在空页。
配置与操作
列表入参继承 PageParam,返回 PageResult。前端搜索先重置页码。排序字段走 sortingFields。
开启见 框架层。
