目录前言一、 更新后端 API 支持分页二、 安装分页组件三、 创建分页组件四、 更新 UserList 组件支持分页五、 运行效果总结前言在上一章中我们完成了用户管理的基础列表查询功能。本章我们将为列表增加分页查询功能这对于数据量较大的场景至关重要。分页查询可以有效减少单次请求的数据量提升页面加载速度同时提供更好的用户体验。本章目标实现后端分页查询 API支持页码、每页条数前端分页组件展示分页状态管理分页与搜索筛选的联动一、 更新后端 API 支持分页修改src/app/api/users/route.ts添加分页参数处理// src/app/api/users/route.tsimport{NextResponse}fromnext/serverimportprismafrom/lib/prismaimport{getServerSession}fromnext-auth/nextimport{authOptions}from../auth/[...nextauth]/route// GET /api/users - 获取用户列表支持搜索、筛选、排序、分页exportasyncfunctionGET(request:Request){try{constsessionawaitgetServerSession(authOptions)if(!session){returnNextResponse.json({error:未授权},{status:401})}// 解析查询参数const{searchParams}newURL(request.url)constkeywordsearchParams.get(keyword)||// 搜索关键词constdepartmentIdsearchParams.get(departmentId)||// 部门筛选conststatussearchParams.get(status)||// 状态筛选constsortFieldsearchParams.get(sortField)||createdAt// 排序字段constsortOrdersearchParams.get(sortOrder)||desc// 排序方向// 分页参数constpageparseInt(searchParams.get(page)||1)// 当前页码constpageSizeparseInt(searchParams.get(pageSize)||10)// 每页条数// 构建查询条件constwhere:any{}// 关键词搜索姓名、工号、邮箱、手机号if(keyword){where.OR[{name:{contains:keyword,mode:insensitive}},{employeeId:{contains:keyword,mode:insensitive}},{email:{contains:keyword,mode:insensitive}},{phone:{contains:keyword}},]}// 部门筛选if(departmentId){where.departmentIddepartmentId}// 状态筛选if(status){where.statusparseInt(status)}// 构建排序条件constorderBy:any{}orderBy[sortField]sortOrder// 计算分页偏移量constskip(page-1)*pageSize// 并行查询获取总数和分页数据const[total,users]awaitPromise.all([prisma.user.count({where}),prisma.user.findMany({where,orderBy,skip,take:pageSize,include:{department:{select:{id:true,name:true,},},},}),])// 格式化返回数据constformattedUsersusers.map((user)({id:user.id,name:user.name,employeeId:user.employeeId,phone:user.phone,email:user.email,avatar:user.avatar,status:user.status,hireDate:user.hireDate?.toISOString()||null,departmentId:user.departmentId,departmentName:user.department?.name||-,createdAt:user.createdAt.toISOString(),}))// 返回分页数据结构returnNextResponse.json({data:formattedUsers,pagination:{page,pageSize,total,totalPages:Math.ceil(total/pageSize),},})}catch(error){console.error(获取用户列表失败:,error)returnNextResponse.json({error:获取用户列表失败},{status:500})}}关键点解析page和pageSize参数控制分页skip (page - 1) * pageSize计算数据偏移量Promise.all并行查询总数和分页数据提升性能返回结构包含data和pagination两部分二、 安装分页组件# 安装 shadcn 分页组件npx shadcnlatestaddpagination三、 创建分页组件创建src/components/ui/pagination.tsx如果 shadcn 未自动生成// src/components/ui/pagination.tsx import * as React from react import { ChevronLeft, ChevronRight, MoreHorizontal } from lucide-react import { cn } from /lib/utils import { ButtonProps, buttonVariants } from /components/ui/button const Pagination ({ className, ...props }: React.ComponentPropsnav) ( nav rolenavigation aria-labelpagination className{cn(mx-auto flex w-full justify-center, className)} {...props} / ) Pagination.displayName Pagination const PaginationContent React.forwardRef HTMLUListElement, React.ComponentPropsul (({ className, ...props }, ref) ( ul ref{ref} className{cn(flex flex-row items-center gap-1, className)} {...props} / )) PaginationContent.displayName PaginationContent const PaginationItem React.forwardRef HTMLLIElement, React.ComponentPropsli (({ className, ...props }, ref) ( li ref{ref} className{cn(, className)} {...props} / )) PaginationItem.displayName PaginationItem type PaginationLinkProps { isActive?: boolean } PickButtonProps, size React.ComponentPropsa const PaginationLink ({ className, isActive, size icon, ...props }: PaginationLinkProps) ( a aria-current{isActive ? page : undefined} className{cn( buttonVariants({ variant: isActive ? outline : ghost, size, }), className )} {...props} / ) PaginationLink.displayName PaginationLink const PaginationPrevious ({ className, ...props }: React.ComponentPropstypeof PaginationLink) ( PaginationLink aria-labelGo to previous page sizedefault className{cn(gap-1 pl-2.5, className)} {...props} ChevronLeft classNameh-4 w-4 / span上一页/span /PaginationLink ) PaginationPrevious.displayName PaginationPrevious const PaginationNext ({ className, ...props }: React.ComponentPropstypeof PaginationLink) ( PaginationLink aria-labelGo to next page sizedefault className{cn(gap-1 pr-2.5, className)} {...props} span下一页/span ChevronRight classNameh-4 w-4 / /PaginationLink ) PaginationNext.displayName PaginationNext const PaginationEllipsis ({ className, ...props }: React.ComponentPropsspan) ( span aria-hidden className{cn(flex h-9 w-9 items-center justify-center, className)} {...props} MoreHorizontal classNameh-4 w-4 / span classNamesr-onlyMore pages/span /span ) PaginationEllipsis.displayName PaginationEllipsis export { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, }四、 更新 UserList 组件支持分页修改src/components/user/UserList.tsx添加分页功能// src/components/user/UserList.tsx use client import { useState, useEffect } from react import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from /components/ui/table import { Button } from /components/ui/button import { Input } from /components/ui/input import { Badge } from /components/ui/badge import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from /components/ui/select import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from /components/ui/pagination import { Search, Plus, MoreHorizontal, Pencil, Trash2 } from lucide-react import { Avatar, AvatarFallback, AvatarImage } from /components/ui/avatar import { UserFormDialog } from ./UserFormDialog import { DeleteUserDialog } from ./DeleteUserDialog import { ResetPasswordDialog } from ./ResetPasswordDialog import { Lock } from lucide-react // 用户数据类型 interface User { id: string name: string employeeId: string | null phone: string | null email: string | null avatar: string | null status: number hireDate: string | null departmentId: string | null departmentName: string createdAt: string } // 分页数据类型 interface PaginationData { page: number pageSize: number total: number totalPages: number } // 状态映射 const statusMap: Recordnumber, { label: string; variant: default | secondary | destructive | outline } { 1: { label: 在职, variant: default }, 2: { label: 离职, variant: secondary }, 3: { label: 休假, variant: outline }, } // 每页条数选项 const pageSizeOptions [10, 20, 50, 100] export function UserList() { const [users, setUsers] useStateUser[]([]) const [loading, setLoading] useState(true) // 查询条件 const [keyword, setKeyword] useState() const [status, setStatus] useStatestring() const [departmentId, setDepartmentId] useStatestring() // 分页状态 const [pagination, setPagination] useStatePaginationData({ page: 1, pageSize: 10, total: 0, totalPages: 0, }) // 对话框状态 const [formDialogOpen, setFormDialogOpen] useState(false) const [deleteDialogOpen, setDeleteDialogOpen] useState(false) const [resetPasswordDialogOpen, setResetPasswordDialogOpen] useState(false) const [selectedUser, setSelectedUser] useStateany(null) // 获取用户列表 const fetchUsers async () { setLoading(true) try { // 构建查询参数 const params new URLSearchParams() if (keyword) params.append(keyword, keyword) if (status) params.append(status, status) if (departmentId) params.append(departmentId, departmentId) // 添加分页参数 params.append(page, pagination.page.toString()) params.append(pageSize, pagination.pageSize.toString()) const response await fetch(/api/users?${params.toString()}) if (!response.ok) throw new Error(获取用户列表失败) const result await response.json() setUsers(result.data) setPagination(result.pagination) } catch (error) { console.error(加载用户数据失败:, error) } finally { setLoading(false) } } // 初始加载和条件变化时重新获取 useEffect(() { fetchUsers() }, [pagination.page, pagination.pageSize, status, departmentId]) // 搜索按钮点击 const handleSearch () { // 搜索时重置到第一页 setPagination(prev ({ ...prev, page: 1 })) fetchUsers() } // 重置查询 const handleReset () { setKeyword() setStatus() setDepartmentId() setPagination(prev ({ ...prev, page: 1 })) fetchUsers() } // 页码改变 const handlePageChange (page: number) { setPagination(prev ({ ...prev, page })) } // 每页条数改变 const handlePageSizeChange (pageSize: number) { setPagination(prev ({ ...prev, page: 1, pageSize })) } // 打开新增对话框 const handleAdd () { setSelectedUser(null) setFormDialogOpen(true) } // 打开编辑对话框 const handleEdit (user: User) { setSelectedUser(user) setFormDialogOpen(true) } // 打开删除对话框 const handleDelete (user: User) { setSelectedUser(user) setDeleteDialogOpen(true) } // 打开重置密码对话框 const handleResetPassword (user: User) { setSelectedUser(user) setResetPasswordDialogOpen(true) } // 操作成功回调 const handleSuccess () { fetchUsers() } // 生成分页页码 const generatePageNumbers () { const { page, totalPages } pagination const pages: (number | string)[] [] if (totalPages 7) { // 总页数较少显示所有页码 for (let i 1; i totalPages; i) { pages.push(i) } } else { // 总页数较多显示省略号 if (page 3) { pages.push(1, 2, 3, 4, ..., totalPages) } else if (page totalPages - 2) { pages.push(1, ..., totalPages - 3, totalPages - 2, totalPages - 1, totalPages) } else { pages.push(1, ..., page - 1, page, page 1, ..., totalPages) } } return pages } if (loading users.length 0) { return div classNamep-6加载中.../div } return ( div classNamebg-white rounded-lg shadow-sm border border-gray-200 {/* 标题栏 */} div classNameflex items-center justify-between p-6 border-b border-gray-200 div h1 classNametext-xl font-bold text-gray-900用户管理/h1 p classNametext-sm text-gray-500 mt-1 共 {pagination.total} 条记录第 {pagination.page}/{pagination.totalPages} 页 /p /div Button classNamebg-blue-600 hover:bg-blue-700 onClick{handleAdd} Plus classNamew-4 h-4 mr-2 / 新增用户 /Button /div {/* 查询条件 */} div classNamep-6 border-b border-gray-200 bg-gray-50 div classNameflex flex-wrap gap-4 {/* 关键词搜索 */} div classNameflex-1 min-w-[200px] div classNamerelative Search classNameabsolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 / Input placeholder搜索姓名、工号、邮箱、手机号 value{keyword} onChange{(e) setKeyword(e.target.value)} classNamepl-10 onKeyDown{(e) e.key Enter handleSearch()} / /div /div {/* 状态筛选 */} Select value{status} onValueChange{setStatus} SelectTrigger classNamew-[150px] SelectValue placeholder全部状态 / /SelectTrigger SelectContent SelectItem value全部状态/SelectItem SelectItem value1在职/SelectItem SelectItem value2离职/SelectItem SelectItem value3休假/SelectItem /SelectContent /Select {/* 部门筛选 */} Select value{departmentId} onValueChange{setDepartmentId} SelectTrigger classNamew-[180px] SelectValue placeholder全部部门 / /SelectTrigger SelectContent SelectItem value全部部门/SelectItem {/* 部门选项应从API动态获取 */} /SelectContent /Select {/* 每页条数 */} Select value{pagination.pageSize.toString()} onValueChange{(value) handlePageSizeChange(parseInt(value))} SelectTrigger classNamew-[120px] SelectValue placeholder每页条数 / /SelectTrigger SelectContent {pageSizeOptions.map(size ( SelectItem key{size} value{size.toString()} {size} 条/页 /SelectItem ))} /SelectContent /Select {/* 操作按钮 */} div classNameflex gap-2 Button variantoutline onClick{handleSearch} 查询 /Button Button variantghost onClick{handleReset} 重置 /Button /div /div /div {/* 数据表格 */} div classNamep-0 Table TableHeader TableRow classNamebg-gray-50 TableHead姓名/TableHead TableHead工号/TableHead TableHead部门/TableHead TableHead手机号/TableHead TableHead邮箱/TableHead TableHead状态/TableHead TableHead入职时间/TableHead TableHead classNametext-right操作/TableHead /TableRow /TableHeader TableBody {users.length 0 ? ( TableRow TableCell colSpan{8} classNametext-center py-10 text-gray-500 暂无用户数据 /TableCell /TableRow ) : ( users.map((user) { const statusInfo statusMap[user.status] || { label: 未知, variant: outline } return ( TableRow key{user.id} classNamehover:bg-gray-50 TableCell div classNameflex items-center gap-3 Avatar classNameh-8 w-8 AvatarImage src{user.avatar || undefined} alt{user.name || } / AvatarFallback classNametext-xs bg-blue-100 text-blue-600 {user.name?.slice(0, 2).toUpperCase() || ?} /AvatarFallback /Avatar span classNamefont-medium{user.name}/span /div /TableCell TableCell classNametext-gray-500{user.employeeId || -}/TableCell TableCell classNametext-gray-500{user.departmentName}/TableCell TableCell classNametext-gray-500{user.phone || -}/TableCell TableCell classNametext-gray-500{user.email || -}/TableCell TableCell Badge variant{statusInfo.variant}{statusInfo.label}/Badge /TableCell TableCell classNametext-gray-500 {user.hireDate ? new Date(user.hireDate).toLocaleDateString(zh-CN) : -} /TableCell TableCell classNametext-right div classNameflex justify-end gap-1 Button variantghost sizeicon onClick{() handleEdit(user)} title编辑 Pencil classNamew-4 h-4 / /Button Button variantghost sizeicon onClick{() handleResetPassword(user)} title重置密码 Lock classNamew-4 h-4 / /Button Button variantghost sizeicon classNametext-red-600 hover:text-red-700 onClick{() handleDelete(user)} title删除 Trash2 classNamew-4 h-4 / /Button /div /TableCell /TableRow ) }) )} /TableBody /Table /div {/* 分页组件 */} {pagination.totalPages 1 ( div classNamep-4 border-t border-gray-200 Pagination PaginationContent {/* 上一页 */} PaginationItem PaginationPrevious onClick{() handlePageChange(pagination.page - 1)} className{pagination.page 1 ? pointer-events-none opacity-50 : cursor-pointer} / /PaginationItem {/* 页码 */} {generatePageNumbers().map((pageNum, index) ( PaginationItem key{index} {pageNum ... ? ( PaginationEllipsis / ) : ( PaginationLink isActive{pageNum pagination.page} onClick{() handlePageChange(pageNum as number)} classNamecursor-pointer {pageNum} /PaginationLink )} /PaginationItem ))} {/* 下一页 */} PaginationItem PaginationNext onClick{() handlePageChange(pagination.page 1)} className{pagination.page pagination.totalPages ? pointer-events-none opacity-50 : cursor-pointer} / /PaginationItem /PaginationContent /Pagination /div )} {/* 对话框组件 */} UserFormDialog open{formDialogOpen} onOpenChange{setFormDialogOpen} user{selectedUser} onSuccess{handleSuccess} / DeleteUserDialog open{deleteDialogOpen} onOpenChange{setDeleteDialogOpen} user{selectedUser} onSuccess{handleSuccess} / ResetPasswordDialog open{resetPasswordDialogOpen} onOpenChange{setResetPasswordDialogOpen} userId{selectedUser?.id || } userName{selectedUser?.name || } onSuccess{handleSuccess} / /div ) }关键点解析pagination状态管理分页信息generatePageNumbers函数智能生成页码带省略号搜索时自动重置到第一页分页组件显示上一页/下一页和页码标题栏显示总记录数和当前页码信息五、 运行效果启动项目npmrun dev进入用户管理页面表格底部显示分页组件可以切换每页显示 10/20/50/100 条点击页码切换页面搜索时自动回到第一页标题显示 “共 X 条记录第 X/X 页”总结本章我们在用户管理基础上增加了分页功能后端分页使用skip和take实现数据库分页查询分页组件使用 shadcn Pagination 组件状态管理分页参数与查询条件联动智能页码根据总页数动态显示省略号