1. 为什么先做用户管理的数据模型与列表查询
这个系列写到这里,前面几篇已经把系统的脚手架、权限框架、基础配置都铺好了。我知道很多跟着教程走到这里的同学,最想问的问题其实是:管理后台里最核心的模块到底怎么从零开始搭?
拿 MBA 培训管理系统来说,用户管理是所有业务模块的地基。学员报名要看学员信息、教务要分配班级、老师要查看授课列表、管理员要做角色分配——这些动作背后,全部指向同一张表:用户表。所以这个模块的建模质量和查询效率,直接决定了后面课表、成绩、考勤这些模块开发的时候,是顺手还是痛苦。
这篇博文只聚焦“数据模型 + 列表查询”两个点,不聊接口权限,不聊新增编辑删除,那些放在下一篇“用户管理(下)”里展开。这样拆分的好处是,单篇文章的复杂度可控,你可以照着敲一遍代码,真正理解一个后台列表页从前到后的完整链路是怎么打通的。
具体来说,这篇内容适合三类人看:一是正在做管理后台但没想清楚表结构怎么设计的新手,二是想把列表查询的代码写得更规范、更高效的初级开发,三是已经在做 MBA 教育或类似培训系统、想找一个完整参考实现的项目负责人。无论你是哪种,这篇都尽量写给“能直接抄作业”的程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据模型设计:从一个业务需求推导出来的表结构
2.1 用户角色拆解是建模的第一步
动手建表之前,先别急着写 SQL。你需要先把“用户”这个词拆开来看。在 MBA 培训管理系统里,用户从来不是一个单一概念,至少包含四类角色:
- 管理员:负责系统配置、教师分配、班级管理,权限最大。
- 教师:负责授课、录入成绩、查看所带班级学员。
- 学员:报名课程、查看课表、提交作业、查询成绩。
- 教务人员:日常业务操作的核心角色,排课、调课、学员服务。
有些人可能会说,那我把 role 字段设计成一个 int 或者 string,user 表里加一列 role,不就行了吗?
确实行,小项目这么干完全没问题。但我们的系统后面要接权限框架,每个角色会对应不同的菜单、按钮、接口权限。如果角色直接硬编码在用户表里,后面做动态权限分配、多角色支持、角色-菜单关联都会非常痛苦。所以我选择把角色拆成独立的表,用户表只存 role_id,外键关联。
这其实是一个经典的“数据表范式化”选择:用户表不存冗余的角色名,而是存角色 ID,通过 Join 查询拿到角色名。好处是角色改名时不用批量更新用户表,坏处是查询时需要多一次关联。在实际业务里,这个代价完全值得支付,因为用户表的查询频率远高于角色表的修改频率。
2.2 用户表字段逐段说明
直接看建表 SQL,然后我逐个字段解释为什么这么设计:
sql复制CREATE TABLE `sys_user` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`username` varchar(50) NOT NULL COMMENT '登录账号',
`password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)',
`real_name` varchar(50) NOT NULL COMMENT '真实姓名',
`phone` varchar(20) DEFAULT NULL COMMENT '手机号',
`email` varchar(100) DEFAULT NULL COMMENT '邮箱',
`role_id` bigint(20) NOT NULL COMMENT '角色ID',
`status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态:1启用 0禁用',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像地址',
`last_login_time` datetime DEFAULT NULL COMMENT '最后登录时间',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除:0未删除 1已删除',
PRIMARY KEY (`id`),
KEY `idx_username` (`username`),
KEY `idx_role_id` (`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
逐段过一遍关键设计决策:
username:登录账号,全局唯一。表结构里我没直接加 UNIQUE 约束,是因为真实业务里账号可能被逻辑删除后,回收给新用户使用。如果加了唯一索引,逻辑删除的账号就无法再次注册。这里我选择用代码层面做唯一性校验,而不是数据库的硬约束。当然,如果你确定账号不可复用,那直接加 UNIQUE KEY uk_username 也是标准的正规做法。
password:长度设为 100,不是 varchar(32)。很多老系统用 MD5 加密,32 位长度足够,但 MD5 早已不安全。我在系统里用的是 BCrypt 加密,BCrypt 生成的哈希串长度固定是 60 位,varchar(100) 留足了余量。这一点建议不要省,后面接 Spring Security 或者 Shiro 的时候,密码字段长度不够直接报错。
status:用户启禁用状态。为什么单独拉出来而不是直接 DELETE 掉用户?因为学员可能只是暂时休学,教师可能只是本周没排课,物理删除会丢失历史关联数据(比如这个用户之前的考勤、成绩记录)。所以“状态”字段承担了软停用的功能。
create_by:记录创建人。管理后台里做数据审计时非常有用,排查“这个用户是谁建的”“那个账号为什么出现”的时候,一个 create_by 可以少扯很多皮。
deleted:逻辑删除标志。这个字段和大伙儿在项目里经常听到的“软删除”是一个意思。所有删除操作都走 UPDATE,而不是 DELETE,这样数据不会物理消失,后面做数据恢复、历史追溯都有退路。代价是每次查询都需要带上 deleted = 0 条件,这个我在后面的 Mapper 里会处理。
2.3 角色表的一并设计
用户表定了,角色表就是顺理成章的事:
sql复制CREATE TABLE `sys_role` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`role_name` varchar(50) NOT NULL COMMENT '角色名称',
`role_code` varchar(50) NOT NULL COMMENT '角色编码',
`description` varchar(255) DEFAULT NULL COMMENT '角色描述',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
这里多了一个 role_code 字段,统一用 ADMIN、TEACHER、STUDENT、STAFF 这种英文编码。为什么要加这个字段?
因为代码里面判断角色逻辑的时候,你不能写死中文角色名“管理员”,万一哪天产品经理脑子一热要把“管理员”改成“超级管理员”,你的代码就得跟着改。而 role_code 这个编码是稳定的业务标识,代码里只认编码,中文名随便改,不影响逻辑。
初始化数据时顺手插入四条角色记录:
sql复制INSERT INTO `sys_role` (`role_name`, `role_code`, `description`) VALUES
('超级管理员', 'ADMIN', '系统最高权限'),
('教师', 'TEACHER', '课程教学与成绩管理'),
('学员', 'STUDENT', '课程学习与作业提交'),
('教务人员', 'STAFF', '排课与学员服务');
2.4 索引设计:列表查询的效率命脉
表结构里有两个索引:idx_username 和 idx_role_id。可能在数据量小的时候感觉不到差别,但 MBA 培训机构的学员规模通常在几千到几万,加上历史学员数据,列表页按用户名搜索、按角色过滤就是高频操作。
没有索引的时候,MySQL 走全表扫描,每一行都要匹配一次 where 条件。加了索引之后,走 B+ 树查找,复杂度从 O(n) 降到 O(log n)。数据越多,差距越明显。
这里多说一句:索引不是越多越好。每一个索引都会拖慢写入速度,占用额外磁盘空间。最怕的是给每个字段都建一个索引,然后查询时根本没有走到索引。我建索引的原则很简单——只有真正高频出现在 where 条件里的字段才值得建索引。username 用于登录和搜索,role_id 用于角色过滤,这两个是确定的高频查询路径。
3. 实体类与数据访问层:让表结构进入代码世界
3.1 实体类设计
表结构落到代码里,对应的是实体类。我用的是 MyBatis-Plus,所以实体类上直接用注解完成表映射,省去 XML 里繁琐的 resultMap:
java复制@Data
@TableName("sys_user")
public class SysUser {
@TableId(type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String realName;
private String phone;
private String email;
private Long roleId;
private Integer status;
private String avatar;
private LocalDateTime lastLoginTime;
private String createBy;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableLogic
private Integer deleted;
}
这个类本身不难,但有几个注解值得展开说一下。
@TableLogic 是 MyBatis-Plus 的逻辑删除注解。加上它之后,你调用 deleteById() 方法时,MyBatis-Plus 不会发 DELETE 语句,而是自动帮你转成 UPDATE sys_user SET deleted = 1 WHERE id = ?。更关键的是,查询时它会自动拼接 deleted = 0 条件,你写的查询 SQL 里完全不需要手动加这个条件。
@TableField(fill = FieldFill.INSERT) 是自动填充注解。create_time 和 update_time 字段,你希望在 insert 时自动写入当前时间,update 时自动更新。配合 MetaObjectHandler 实现类,代码里完全不用手动 set 时间,省掉一批重复劳动。
3.2 自动填充处理器
我的 MetaObjectHandler 实现长这样:
java复制@Component
public class MyMetaObjectHandler implements MetaObjectHandler {
@Override
public void insertFill(MetaObject metaObject) {
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
@Override
public void updateFill(MetaObject metaObject) {
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
}
}
这段代码的力量在于:以后不管是在用户模块、课程模块还是成绩模块,只要实体类里的时间字段标注了 @TableField(fill = ...),插入和更新时都会自动填充时间,你永远不用担心“啊我忘了 set 时间导致数据库里时间是空的”。
3.3 用户查询参数的封装
列表查询和普通按主键查询最大的区别是:查询条件是动态组合的。用户可能按用户名搜,可能按角色筛,可能只看启用状态,也可能什么都不输入直接查全部。
所以查询参数不能散落在 Controller 的方法参数列表里——参数一多,方法签名就变得不可维护。我习惯的做法是封装一个 Query 对象:
java复制@Data
public class UserQuery {
private String username;
private String realName;
private Long roleId;
private Integer status;
private Integer pageNum = 1;
private Integer pageSize = 10;
}
用这个对象接收前端传过来的筛选条件和分页参数。这里 pageNum 和 pageSize 都有默认值,避免前端漏传参数时把数据库全表拉出来。
3.4 Mapper 层:一个继承解决的查询
Mapper 接口本身没什么特别的,继承 MyBatis-Plus 提供的 BaseMapper 后,单表 CRUD 基本就有了:
java复制@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}
真正需要写 SQL 的地方在于“带条件的分页查询”。MyBatis-Plus 提供了两种方案:
方案一,用 LambdaQueryWrapper 构造条件,配合分页插件,这个方案适合单表查询且条件相对简单的场景,不需要写 SQL。
方案二,在 Mapper XML 里手写 SQL,适合多表 Join 查询,比如用户列表需要关联角色表查出 roleName。我们这里的列表确实需要显示角色名,所以手写 SQL 更合适。
我先给出 Service 层通过 LambdaQueryWrapper 实现基础分页的写法——因为这个方案用代码就能表达查询条件,非常直观:
java复制@Override
public Page<SysUser> pageUsers(UserQuery query) {
Page<SysUser> page = new Page<>(query.getPageNum(), query.getPageSize());
LambdaQueryWrapper<SysUser> wrapper = new LambdaQueryWrapper<>();
wrapper.like(StringUtils.hasText(query.getUsername()), SysUser::getUsername, query.getUsername())
.like(StringUtils.hasText(query.getRealName()), SysUser::getRealName, query.getRealName())
.eq(query.getRoleId() != null, SysUser::getRoleId, query.getRoleId())
.eq(query.getStatus() != null, SysUser::getStatus, query.getStatus())
.orderByDesc(SysUser::getCreateTime);
return userMapper.selectPage(page, wrapper);
}
这段代码的核心逻辑就是动态拼接 SQL 条件。比如前端传了 username,SQL 里就有 WHERE username LIKE '%xxx%';前端没传,这个条件就不拼进去。LambdaQueryWrapper 的特点是类型安全,字段名字写错编译期就能发现,不会等到运行期报错。
如果要带出角色名,可以再关联一个查询组装 VO,或者直接走 XML 手写联表 SQL。我项目里选择的是基于上面代码查询出用户列表后,再用角色 ID 批量查出角色名,组装到返回对象中,这样避免了大 SQL 的维护成本,性能也能通过“一次 IN 查询”保证。
4. 后端列表查询接口:从 Controller 到 SQL 的完整链路
4.1 Controller 层设计
Controller 层要干的事情很少:接收参数、调用 Service、返回统一结果。
java复制@RestController
@RequestMapping("/api/user")
public class SysUserController {
@Autowired
private SysUserService sysUserService;
@PostMapping("/page")
public Result<PageResult<SysUserVO>> page(@RequestBody @Valid UserQuery query) {
PageResult<SysUserVO> pageResult = sysUserService.pageUsers(query);
return Result.success(pageResult);
}
}
为什么用 POST 而不是 GET?因为列表查询条件多,如果全部拼在 URL 上,一是 URL 太长,二是中文参数需要 URL 编码,三是 GET 请求的查询条件会被浏览器记录在历史里,安全性差一些。所以在管理后台这种内部系统,列表查询我统一用 POST + JSON Body。
统一返回 Result 包装类,这个系列前面几篇已经定义过,它的结构是 { code: 200, data: ..., message: "success" }。所有接口都走这个包装,前端拿到数据后统一处理,不用每个接口各写一套异常处理。
4.2 分页插件的配置与逻辑
分页是列表查询的标配功能。MyBatis 生态里最常用的是 PageHelper,但 MyBatis-Plus 集成了自己的分页插件 PaginationInnerInterceptor,配置方式更简单,直接注入一个 MybatisPlusInterceptor Bean 就行:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
paginationInterceptor.setMaxLimit(100L);
interceptor.addInnerInterceptor(paginationInterceptor);
return interceptor;
}
}
注意这里我设置了 setMaxLimit(100L),意思是单页最大查询条数不能超过 100。为什么要加这个限制?
因为总有前端调用时手滑传一个 pageSize=10000,或者恶意请求直接拉全量数据。不限流的话,数据库瞬间被打满。设置上限之后,超过 100 会被自动拦截或截断,保护后端服务。
分页插件的工作原理是在执行你写的 SQL 之前,自动拦截并生成一条 COUNT 查询和一条带 LIMIT 的分页查询。你写的 SQL 完全不用关心分页语法,插件帮你处理。
4.3 扩展:按角色筛选的 SQL 细节
如果用户列表还要显示角色名称,最稳妥的做法是在 Mapper XML 里写一个联表查询:
xml复制<select id="selectUserPageWithRole" resultType="com.example.vo.SysUserVO">
SELECT
u.id,
u.username,
u.real_name,
u.phone,
u.email,
u.role_id,
r.role_name,
r.role_code,
u.status,
u.avatar,
u.last_login_time,
u.create_time
FROM sys_user u
LEFT JOIN sys_role r ON u.role_id = r.id
WHERE u.deleted = 0
<if test="query.username != null and query.username != ''">
AND u.username LIKE CONCAT('%', #{query.username}, '%')
</if>
<if test="query.roleId != null">
AND u.role_id = #{query.roleId}
</if>
<if test="query.status != null">
AND u.status = #{query.status}
</if>
ORDER BY u.create_time DESC
</select>
但在本项目里,我走的是 LambdaQueryWrapper 方案,因为查询逻辑简单,不需要动态表名和复杂子查询,代码可读性更好。如果你选择 XML 方案,记得 u.deleted = 0 必须写——@TableLogic 注解只对 MyBatis-Plus 自动生成的 SQL 生效,手写 SQL 里的逻辑删除条件必须自己加,这是非常容易踩的坑。
4.4 结果对象组装:不把实体类直接暴露给前端
接口返回给前端时,我一般不直接返回 SysUser 实体类,而是组装一个 VO(View Object):
java复制@Data
public class SysUserVO {
private Long id;
private String username;
private String realName;
private String phone;
private String email;
private Long roleId;
private String roleName;
private String roleCode;
private Integer status;
private String avatar;
private LocalDateTime lastLoginTime;
private LocalDateTime createTime;
}
注意这个 VO 里没有 password。为什么?
实体类里包含密码字段,你永远不知道哪天哪个同事把实体类直接序列化返回给前端了,密码就泄露了。即使是后端内部系统,密码这种敏感信息也只能是单向写入,绝不能出现在任何查询返回值里。我在做数据库表设计的时候直接把 password 放在 sys_user 表里,但返回值永远是 VO,不碰实体类。
组装 VO 的代码在 Service 层完成,先分页查用户,再根据 roleId 集合批量查角色,循环组装:
java复制public PageResult<SysUserVO> pageUsers(UserQuery query) {
Page<SysUser> page = userMapper.selectPage(new Page<>(query.getPageNum(), query.getPageSize()), buildWrapper(query));
if (page.getRecords().isEmpty()) {
return PageResult.of(page.getTotal(), Collections.emptyList());
}
Set<Long> roleIds = page.getRecords().stream().map(SysUser::getRoleId).collect(Collectors.toSet());
List<SysRole> roles = roleMapper.selectBatchIds(roleIds);
Map<Long, SysRole> roleMap = roles.stream().collect(Collectors.toMap(SysRole::getId, Function.identity()));
List<SysUserVO> voList = page.getRecords().stream().map(user -> {
SysUserVO vo = new SysUserVO();
BeanUtils.copyProperties(user, vo);
SysRole role = roleMap.get(user.getRoleId());
if (role != null) {
vo.setRoleName(role.getRoleName());
vo.setRoleCode(role.getRoleCode());
}
return vo;
}).collect(Collectors.toList());
return PageResult.of(page.getTotal(), voList);
}
这里有个核心性能思想:在循环外先批量查询角色,再在循环里从 Map 中取值,避免了 N+1 查询问题。N+1 即查完用户列表后,循环里每查一个用户再查一次角色,如果一页 20 个用户就要查 20 次角色表。批量查一次搞定,接口响应时间直接下降一个数量级。
5. 前端列表查询页面实现
5.1 查询表单与列表页面的结构
后端接口准备好了,前端页面就好办了。我这套前端用的是 Vue 3 + Element Plus,实际大厂项目也大多是这套体系。列表页的核心结构分为三块:上方的搜索表单、中间的表格、下方的分页器。
vue复制<template>
<div class="user-page">
<el-card shadow="never" class="search-card">
<el-form :inline="true" :model="queryForm" @submit.prevent>
<el-form-item label="用户名">
<el-input v-model="queryForm.username" placeholder="请输入用户名" clearable />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="queryForm.roleId" placeholder="请选择角色" clearable>
<el-option
v-for="role in roleOptions"
:key="role.id"
:label="role.roleName"
:value="role.id"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="queryForm.status" placeholder="请选择状态" clearable>
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="username" label="用户名" min-width="120" />
<el-table-column prop="realName" label="姓名" min-width="100" />
<el-table-column prop="roleName" label="角色" min-width="100" />
<el-table-column prop="status" label="状态" min-width="80">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" min-width="130" />
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="lastLoginTime" label="最后登录时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.lastLoginTime) }}
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="170">
<template #default="{ row }">
{{ formatTime(row.createTime) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="queryForm.pageNum"
v-model:page-size="queryForm.pageSize"
:total="total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
class="pagination-wrap"
@size-change="handleQuery"
@current-change="handleQuery"
/>
</el-card>
</div>
</template>
这是个相当通用的后台列表页模板,四个关键点:
第一,查询表单用了 inline 布局,所有筛选条件水平排布,适合条件少的场景。如果条件超过三个,建议改成栅格布局分两行排布,不然小屏幕会挤变形。
第二,el-select 加上 clearable 属性,允许用户把筛选条件清空。清空之后,v-model 绑定的值会变成 undefined 或空字符串,提交给后端时就不会带上这个筛选条件。
第三,v-loading 在查询期间显示加载动画,防止用户重复点击查询按钮或者认为页面卡死。
第四,分页器的 @size-change 和 @current-change 都绑定了同一个 handleQuery 方法。但要注意一个细节:改变 pageSize 时,页码应该重置为 1,否则你当前在第 5 页,切完每页条数后可能直接跳到一个不存在的页码。
5.2 列表数据获取与状态管理
页面逻辑部分的代码:
javascript复制import { ref, reactive, onMounted } from 'vue'
import { getUserPage } from '@/api/user'
import { getRoleOptions } from '@/api/role'
import { ElMessage } from 'element-plus'
import dayjs from 'dayjs'
const loading = ref(false)
const tableData = ref([])
const total = ref(0)
const roleOptions = ref([])
const queryForm = reactive({
username: '',
roleId: null,
status: null,
pageNum: 1,
pageSize: 10
})
const fetchUserList = async () => {
loading.value = true
try {
const res = await getUserPage(queryForm)
tableData.value = res.data.list
total.value = res.data.total
} catch (error) {
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
const handleQuery = () => {
queryForm.pageNum = 1
fetchUserList()
}
const handleReset = () => {
queryForm.username = ''
queryForm.roleId = null
queryForm.status = null
queryForm.pageNum = 1
queryForm.pageSize = 10
fetchUserList()
}
const formatTime = (time) => {
if (!time) return '-'
return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
}
onMounted(async () => {
fetchUserList()
const roleRes = await getRoleOptions()
roleOptions.value = roleRes.data
})
有几个细节是新手很容易忽略的:
重置按钮不应只是清空表单。重置的逻辑是:表单条件全部复位 + 页码回到第 1 页 + 重新请求后端。我见过不少只清空表单、然后数据还停留在第 3 页的 bug,用户一脸懵。
handleQuery 里强制把 pageNum 重置为 1。你正在第 5 页,输入一个很窄的关键词搜索,结果可能只有 2 条数据,如果页码还是 5,页面上就是空白。正确的做法是任何筛选条件变化后,查询都从第 1 页重新开始。
dayjs 格式化时间。后端 LocalDateTime 返回的格式一般是 2025-01-15T14:23:11,中间有个 T,前端直接显示非常丑。用 dayjs 格式化后变成 2025-01-15 14:23:11,更符合国内用户习惯。
5.3 全选按钮的那点事
很多人在开发列表页时会遇到全选按钮的坑。Element Plus 的 el-table 自带多选功能,只要加一列 type="selection" 就行:
vue复制<el-table-column type="selection" width="50" />
这个自带的全选按钮默认选中当前页所有行,数据刷新后选中状态会被清空。这里有两个点要注意:
一是如果你需要在翻页之后保留选中状态,必须自己维护一个 selectedRows 数组,监听表格的 selection-change 事件,手动合并选中项。Element Plus 原生翻页后会丢失选中状态,因为表格行的引用已经变了。
二是全选按钮和批量操作按钮联动。比如“批量禁用”按钮,必须等用户选中至少一行才能可点击。这个可以用计算属性:
javascript复制const selectedRows = ref([])
const handleSelectionChange = (rows) => {
selectedRows.value = rows
}
const canBatch = computed(() => selectedRows.value.length > 0)
这些是列表查询页面非常重要但常常被忽略的实用性细节。
5.4 与后端接口的对接说明
前端调用后端的 API 层,我用的是 axios 封装好的请求方法。基础定义如下:
javascript复制import request from '@/utils/request'
export const getUserPage = (data) => {
return request.post('/api/user/page', data)
}
export const getRoleOptions = () => {
return request.get('/api/role/options')
}
这里有一个实践建议:接口路径不要用动词,比如不要写成 /api/user/getUserPage。RESTful 风格的路径 /api/user/page + POST 动作已经把语义表达清楚了。而且接口路径尽量是名词复数,一眼看出来它在操作什么资源。
axios 请求拦截器里,我会统一加上 token 头,方便后端做认证。响应拦截器里,判断 HTTP 状态码和业务状态码,业务失败统一弹错误提示。这样前端每个接口调用的代码里就不需要反复写错误处理逻辑。
6. 常见问题与排查技巧实录
6.1 前端列表页面出现空白
这个是我见过最多的新手问题排查场景。后端接口明明返回了数据,但页面表格就是空白的。
按照顺序排查:
第一,F12 打开浏览器控制台,看 Network 里接口返回了没有。如果返回 500,说明后端报错,去看后端日志。
第二,接口返回 200 且有数据,但页面空白,检查后端返回的 list 字段名和前端取值的字段名是否一致。比如后端返回 records,前端取 list,那自然是空的。这就是后端 VO 和前端响应结构的约定问题,务必保证统一。
第三,检查表格的 prop 字段名和实际数据字段名是否完全一致,大小写、下划线命名都会导致取不到值。数据库表字段是 real_name,后端实体属性是 realName,但 JSON 序列化如果你没有配置驼峰转下划线,返回的就是 realName,前端 prop="realName" 能取到,但 prop="real_name" 就取不到。
6.2 查询条件丢失或者永不过滤
还有个典型问题:前端传了筛选条件,但后端查询结果没有过滤。这个一般是参数绑定问题。
比如前端传 roleId: undefined,axios 默认不会把 undefined 的字段放进请求体里,后端接收到的 JSON 对象里就没有 roleId 这个 key,自然没触发查询。
另一种情况是后端判断逻辑写错。前端传 status: 0,后端代码里用 if (query.getStatus() != null) 判断,这没问题,但如果你写的是 if (StringUtils.hasText(query.getStatus())) 或者 if (query.getStatus() == 1),那 status=0 这个合法值就被漏掉了。排查这类问题最好的办法是在后端 Controller 入口打印一下完整的请求参数,看看到底收到了什么。
6.3 分页数据对不上
分页总数不对、当前页数据重复,很大概率是前端 pageNum 和 pageSize 的起始约定不一致。后端 Page 默认 pageNum 从 1 开始,有的前端组件从 0 开始,两者相差一页,数据就全错位了。
排查方法很简单:后端日志里打印分页参数,看实际收到的 pageNum 是多少;再和数据库总记录数对比一下,确认是否差一页。如果前后端都是 1 起始,通常不会有这个问题。
6.4 逻辑删除字段引发的灵异事件
这是我踩过最隐蔽的坑。有一次手写 XML 查询时忘了加 deleted = 0 条件,列表页就出现了所谓的“已删除用户”。更诡异的是,偶尔有,偶尔没有,排查了半天才发现是逻辑删除字段没过滤。
记住:@TableLogic 只对 MyBatis-Plus 的自动 SQL 生效,手写 XML 必须自己加条件。如果你是混合使用(部分走 Wrapper,部分走 XML),一定要在 XML 里手动带上 WHERE deleted = 0,最好像我一样在团队的代码规范里强制约定这一点。
6.5 分页插件和手写 SQL 冲突
还有一点需要注意,如果你的项目同时用了 MyBatis-Plus 分页插件、又自己手写 XML SQL,分页插件会拦截你手写的查询语句并自动包一层 COUNT 和 LIMIT。如果你的 SQL 里有 GROUP BY 或者 DISTINCT,COUNT 语句可能会统计错乱,导致 total 数量不对。
解决办法是,对存在 GROUP BY 的复杂统计查询,不要用分页插件,直接在 Service 层手写 COUNT 和分页两条 SQL,自己控制分页逻辑。
7. 实际跑起来的几点体会
整个用户管理列表查询模块写下来,前后端的代码量不大,但涉及的设计点其实不少。从表结构的三范式与反范式权衡,到逻辑删除和唯一索引的取舍,从后端 VO 防密码泄露,到前端查询表单的分页重置,每个决策都有它背后的业务考量。
我给自己的团队定的规矩是:列表查询接口必须统一走 Page + Query 的模式,谁也不要自己另起炉灶搞一套。越到项目后期,模块越多,统一模式带来的维护收益就越大。你不需要去猜每个列表接口的参数名是什么、返回结构是什么,所有页面照着同一个模板抄就行了。
关于数据模型,还有一个建议:表结构设计之初就考虑三年后的数据量。用户表这种核心表,不要省索引,不要省冗余字段。反正后面加字段也是家常便饭,但每次加字段都伴随着一次发布和一次数据库变更,能一次性想清楚就一次到位。
最后分享一个小技巧,在你本地开发时,可以在全局异常处理器里把 SQL 异常信息打印出来。很多列表查询的问题,比如字段不存在、SQL 语法错误,看日志一眼就能定位。别等到前端页面报错再来猜,后端日志才是第一手资料。
下一篇我会继续写用户管理模块的新增、编辑、删除以及密码重置逻辑,这些操作涉及数据校验、重复性检查、敏感信息处理,坑也不少。等那篇文章出来,用户管理模块就能完整落地了,到时候整个系统的骨架也就基本齐了。
