前几篇我们搞定了登录、权限和动态菜单。现在,系统的大门已经打开,是时候管理那些“进门的人”了。本篇将手把手带你实现一个标准的企业级“用户管理”模块。我们将重点攻克多条件模糊搜索、SQL 分页与连表查询(JOIN)的性能优化,以及如何在前端优雅地处理Loading 状态与数据回显。
学习之前先浏览 前置专栏文章
一、 引言:不仅仅是 CRUD
很多新手在写“用户管理”时,往往只是简单地 SELECT * FROM sys_users。但在真实的企业场景中,我们面临着这样的挑战:
- 关联数据:用户表只有 role_id,但前端要显示“超级管理员”这个中文名。
- 海量数据:如果有 10 万个用户,一次性查出来浏览器会卡死,必须分页。
- 复杂搜索:老板说,“我要搜所有姓张的、并且是经理角色的、且账号状态是启用的用户”。
本篇将通过实现这些需求,带你掌握后端 SQL 组装的核心技巧。
二、 后端实现:打造高性能列表接口
1. 数据库回顾
我们主要涉及两张表:
- sys_users (用户表):存了 username, nickname, role_id 等。
- sys_roles (角色表):存了 role_name。
2. 核心难点:动态 SQL 与分页
我们需要在一个接口 (GET /user/list) 中同时处理:搜索、分页、排序、连表。
文件:routes/user.js
router.get('/list', authMiddleware, async (req, res, next) => {
try {
// 1. 获取前端参数,并设置默认值
let { pageNum = 1, pageSize = 10, username, phone, status } = req.query
// 转换类型,防止 SQL 注入或计算错误
pageNum = parseInt(pageNum)
pageSize = parseInt(pageSize)
const offset = (pageNum - 1) * pageSize
// 2. 构建基础 SQL
// 技巧:WHERE 1=1 是为了后面方便无脑拼接 AND 语句
let whereSql = ' WHERE 1=1 '
let params = []
// 3. 动态拼接查询条件
if (username) {
whereSql += ' AND u.username LIKE ? '
params.push(`%${username}%`) // 模糊查询
}
if (phone) {
whereSql += ' AND u.phone LIKE ? '
params.push(`%${phone}%`)
}
if (status !== undefined && status !== '') {
whereSql += ' AND u.status = ? '
params.push(status)
}
// 4. 查询总条数 (用于前端计算分页)
// 优化点:查总数不需要 LEFT JOIN,只查主表即可,性能更高
const countSql = `SELECT COUNT(*) as total FROM sys_users u ${whereSql}`
const [countResult] = await pool.query(countSql, params)
const total = countResult[0].total
// 5. 查询当前页数据 (LEFT JOIN 角色表)
// 优化点:只查询需要的字段,不要 SELECT * (虽然写 * 方便,但在大表中影响性能)
const listSql = `
SELECT u.id, u.username, u.nickname, u.phone, u.status, u.created_at, u.role_id,
r.role_name
FROM sys_users u
LEFT JOIN sys_roles r ON u.role_id = r.id
${whereSql}
ORDER BY u.created_at DESC
LIMIT ? OFFSET ?
`
// 注意:LIMIT 和 OFFSET 的参数要追加在 params 后面
const [rows] = await pool.query(listSql, [...params, pageSize, offset])
res.json({
code: 200,
data: {
list: rows,
total,
pageNum,
pageSize
}
})
} catch (err) {
next(err)
}
})
3. SQL 优化深度解析
我在上面的代码中运用了几个关键的优化思想:
-
Count 分离:
- 很多 ORM 框架喜欢生成一个包含 JOIN 的 Count 语句。
- 优化前:SELECT COUNT(*) FROM users u LEFT JOIN roles r ... (慢,因为要关联两张表)。
- 优化后:SELECT COUNT(*) FROM users u ... (快,只查主表)。因为关联角色并不会改变用户的总行数。
-
LEFT JOIN 获取角色名:
- 新手写法:先查出 10 个用户,然后写个 for 循环,循环 10 次去查角色表。这就是著名的 N+1 问题,性能极差。
- 高手写法:一个 LEFT JOIN 直接把关联数据带出来,数据库层面一次搞定。
-
按需 Select:
- 我明确指定了 u.id, u.username... 而不是 u.*。
- 原因:用户表里可能包含 password(加密串)、token 等敏感或大字段,不仅浪费带宽,还容易泄露安全信息。
三、 前端实现:交互细节打磨
文件:views/system/user/index.vue
<template> <div class="p-6">
... form表单和table 展示就行
</template>
2. 逻辑实现 (Composition API)
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { getUserList, updateUserStatus, deleteUser } from '@/api/user'
import { ElMessage, ElMessageBox } from 'element-plus'
// 响应式状态
const loading = ref(false)
const userList = ref([])
const total = ref(0)
// 查询参数:与后端接口字段一一对应
const queryParams = reactive({
pageNum: 1,
pageSize: 10,
username: '',
phone: '',
status: undefined // undefined 时后端不拼接 SQL,这很重要
})
// 核心:获取列表
const getList = async () => {
loading.value = true
try {
// 调用 API,Axios 会自动过滤 undefined 的参数
const res: any = await getUserList(queryParams)
userList.value = res.list
total.value = res.total
} finally {
loading.value = false
}
}
// 搜索事件
const handleQuery = () => {
// 搜索时,通常建议把页码重置为 1,防止你在第 10 页搜一个只有 1 条数据的结果时表格为空
// 但如果是翻页事件(@current-change)触发的,则不重置
// 这里做一个简单处理,翻页组件会自动更新 queryParams.pageNum,我们直接调接口即可
getList()
}
// 重置事件
const resetQuery = () => {
// 手动清空参数
queryParams.username = ''
queryParams.phone = ''
queryParams.status = undefined
queryParams.pageNum = 1 // 重置回第一页
getList()
}
// 状态修改
const handleStatusChange = async (row: any) => {
try {
await updateUserStatus({ id: row.id, status: row.status })
ElMessage.success('状态更新成功')
} catch (error) {
// 接口失败,把开关状态切回去
row.status = row.status === 1 ? 0 : 1
}
}
// 格式化时间
const formatTime = (time: string) => {
if(!time) return ''
return new Date(time).toLocaleString() // 简单格式化,生产环境建议用 dayjs
}
onMounted(() => {
getList()
})
</script>
四、 进阶:索引与性能优化 (Senior Tips)
当用户量达到 10 万级、100 万级时,上面的代码会变慢。作为一名资深开发,你需要提前布局:
添加索引 (Index)
SQL 中的 WHERE 条件和 ORDER BY 字段必须有索引。
-- 给搜索字段加索引
ALTER TABLE sys_users ADD INDEX idx_username (username);
ALTER TABLE sys_users ADD INDEX idx_phone (phone);
-- 给排序字段加索引
ALTER TABLE sys_users ADD INDEX idx_created_at (created_at);
-- 给外键加索引 (JOIN 时必用)
ALTER TABLE sys_users ADD INDEX idx_role_id (role_id);
五、 下篇预告
用户列表做好了,但我们还需要给用户分配角色,给角色分配权限。这是 RBAC 的核心枢纽。
在下一篇 《全栈 RBAC 实战 (10):角色管理与权限分配的深度递归逻辑》 中,我们将挑战前端最复杂的组件之一:El-Tree 的回显与半选状态处理,以及如何在后端利用事务保证权限数据的一致性。
敬请期待!
具备完整功能后台管理系统的代码仓库:
感谢大家的star
前端:前端
后端:后端