目录
- 前言
- [一、 更新用户列表组件](#一、 更新用户列表组件)
- [二、 创建批量删除 API](#二、 创建批量删除 API)
- [三、 安装 Checkbox 组件](#三、 安装 Checkbox 组件)
- [四、 运行效果](#四、 运行效果)
- 总结
前言
在之前的章节中,我们已经实现了用户管理的完整功能,包括增删改查、分页、导入导出等。本章我们将为列表增加批量选择 和批量删除功能,提升数据管理效率。
批量操作是企业管理系统的重要功能,它允许管理员一次性处理多条记录,大大提高工作效率。
本章目标:
- 实现表格行的批量选择(复选框)
- 实现跨分页保持选中状态
- 实现批量删除功能
- 全选/取消全选功能
一、 更新用户列表组件
修改 src/components/user/UserList.tsx,添加批量选择功能:
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 { Checkbox } from "@/components/ui/checkbox"
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, Pencil, Trash2, Download, Upload, AlertTriangle } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { UserFormDialog } from "./UserFormDialog"
import { DeleteUserDialog } from "./DeleteUserDialog"
import { ResetPasswordDialog } from "./ResetPasswordDialog"
import { ExportDialog } from "./ExportDialog"
import { ImportDialog } from "./ImportDialog"
import { Lock } from "lucide-react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from "@/components/ui/dialog"
// 用户数据类型
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: Record<number, { 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] = useState<User[]>([])
const [loading, setLoading] = useState(true)
// 查询条件
const [keyword, setKeyword] = useState("")
const [status, setStatus] = useState<string>("")
const [departmentId, setDepartmentId] = useState<string>("")
// 分页状态
const [pagination, setPagination] = useState<PaginationData>({
page: 1,
pageSize: 10,
total: 0,
totalPages: 0,
})
// 批量选择状态 - 使用 Set 存储选中的用户 ID
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
// 对话框状态
const [formDialogOpen, setFormDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false)
const [exportDialogOpen, setExportDialogOpen] = useState(false)
const [importDialogOpen, setImportDialogOpen] = useState(false)
const [selectedUser, setSelectedUser] = useState<any>(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 }))
// 清空选择
setSelectedIds(new Set())
fetchUsers()
}
// 页码改变
const handlePageChange = (page: number) => {
setPagination(prev => ({ ...prev, page }))
}
// 每页条数改变
const handlePageSizeChange = (pageSize: number) => {
setPagination(prev => ({ ...prev, page: 1, pageSize }))
}
// ========== 批量选择相关函数 ==========
// 切换单个行的选中状态
const toggleSelect = (id: string) => {
const newSelected = new Set(selectedIds)
if (newSelected.has(id)) {
newSelected.delete(id)
} else {
newSelected.add(id)
}
setSelectedIds(newSelected)
}
// 切换全选(当前页)
const toggleSelectAll = () => {
const currentPageIds = users.map(user => user.id)
const allSelected = currentPageIds.every(id => selectedIds.has(id))
const newSelected = new Set(selectedIds)
if (allSelected) {
// 取消当前页所有选中
currentPageIds.forEach(id => newSelected.delete(id))
} else {
// 选中当前页所有
currentPageIds.forEach(id => newSelected.add(id))
}
setSelectedIds(newSelected)
}
// 检查当前页是否全选
const isCurrentPageSelected = users.length > 0 && users.every(user => selectedIds.has(user.id))
// 检查是否有部分选中(用于显示 indeterminate 状态)
const isPartialSelected = users.some(user => selectedIds.has(user.id)) && !isCurrentPageSelected
// 打开批量删除对话框
const handleBatchDelete = () => {
setBatchDeleteDialogOpen(true)
}
// 执行批量删除
const handleBatchDeleteConfirm = async () => {
try {
const response = await fetch('/api/users/batch-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selectedIds) }),
})
if (!response.ok) throw new Error('批量删除失败')
// 清空选择并刷新列表
setSelectedIds(new Set())
fetchUsers()
setBatchDeleteDialogOpen(false)
} catch (error) {
console.error('批量删除失败:', error)
alert('批量删除失败,请重试')
}
}
// 打开新增对话框
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 className="p-6">加载中...</div>
}
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
{/* 标题栏 */}
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<div>
<h1 className="text-xl font-bold text-gray-900">用户管理</h1>
</div>
<div className="flex gap-2">
{selectedIds.size > 0 && (
<Button
variant="destructive"
onClick={handleBatchDelete}
>
<Trash2 className="w-4 h-4 mr-2" />
批量删除 ({selectedIds.size})
</Button>
)}
<Button variant="outline" onClick={() => setImportDialogOpen(true)}>
<Upload className="w-4 h-4 mr-2" />
导入
</Button>
<Button variant="outline" onClick={() => setExportDialogOpen(true)}>
<Download className="w-4 h-4 mr-2" />
导出
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleAdd}>
<Plus className="w-4 h-4 mr-2" />
新增用户
</Button>
</div>
</div>
{/* 查询条件 */}
<div className="p-6 border-b border-gray-200 bg-gray-50">
<div className="flex flex-wrap gap-4">
{/* 关键词搜索 */}
<div className="flex-1 min-w-[200px]">
<div className="relative">
<Search className="absolute 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)}
className="pl-10"
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
</div>
</div>
{/* 状态筛选 */}
<Select value={status || "all"} onValueChange={(value) => setStatus(value === "all" ? "" : value)}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="全部状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="1">在职</SelectItem>
<SelectItem value="2">离职</SelectItem>
<SelectItem value="3">休假</SelectItem>
</SelectContent>
</Select>
{/* 部门筛选 */}
<Select value={departmentId || "all"} onValueChange={(value) => setDepartmentId(value === "all" ? "" : value)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="全部部门" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部部门</SelectItem>
</SelectContent>
</Select>
{/* 操作按钮 */}
<div className="flex gap-2">
<Button variant="outline" onClick={handleSearch}>
查询
</Button>
<Button variant="ghost" onClick={handleReset}>
重置
</Button>
</div>
</div>
</div>
{/* 数据表格 */}
<div className="p-0">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[50px]">
<Checkbox
checked={isCurrentPageSelected}
onCheckedChange={toggleSelectAll}
aria-label="全选当前页"
/>
</TableHead>
<TableHead>姓名</TableHead>
<TableHead>工号</TableHead>
<TableHead>部门</TableHead>
<TableHead>手机号</TableHead>
<TableHead>邮箱</TableHead>
<TableHead>状态</TableHead>
<TableHead>入职时间</TableHead>
<TableHead className="text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-10 text-gray-500">
暂无用户数据
</TableCell>
</TableRow>
) : (
users.map((user) => {
const statusInfo = statusMap[user.status] || { label: "未知", variant: "outline" }
const isSelected = selectedIds.has(user.id)
return (
<TableRow
key={user.id}
className={`hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}`}
>
<TableCell>
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleSelect(user.id)}
aria-label={`选择 ${user.name}`}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatar || undefined} alt={user.name || ''} />
<AvatarFallback className="text-xs bg-blue-100 text-blue-600">
{user.name?.slice(0, 2).toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="font-medium">{user.name}</span>
</div>
</TableCell>
<TableCell className="text-gray-500">{user.employeeId || "-"}</TableCell>
<TableCell className="text-gray-500">{user.departmentName}</TableCell>
<TableCell className="text-gray-500">{user.phone || "-"}</TableCell>
<TableCell className="text-gray-500">{user.email || "-"}</TableCell>
<TableCell>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
</TableCell>
<TableCell className="text-gray-500">
{user.hireDate
? new Date(user.hireDate).toLocaleDateString("zh-CN")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(user)}
title="编辑"
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleResetPassword(user)}
title="重置密码"
>
<Lock className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-red-600 hover:text-red-700"
onClick={() => handleDelete(user)}
title="删除"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
{/* 分页组件 */}
<div className="p-4 border-t border-gray-200 flex items-center justify-between">
{/* 左侧:总条数和选中数量 */}
<div className="text-sm text-gray-500">
共 <span className="font-medium text-gray-900">{pagination.total}</span> 条
{selectedIds.size > 0 && (
<span className="ml-2 text-blue-600">
已选中 {selectedIds.size} 条
</span>
)}
</div>
{/* 右侧:每页条数 + 分页 */}
<div className="flex items-center gap-4">
{/* 每页条数 */}
<div className="flex flex-row items-center gap-2 whitespace-nowrap">
<span className="text-sm text-gray-500">每页</span>
<Select
value={pagination.pageSize.toString()}
onValueChange={(value) => handlePageSizeChange(parseInt(value))}
>
<SelectTrigger className="w-[70px] h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map(size => (
<SelectItem key={size} value={size.toString()}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-gray-500">条</span>
</div>
{/* 分页 */}
{pagination.totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
text="上一页"
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)}
className="cursor-pointer"
>
{pageNum}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
text="下一页"
onClick={() => handlePageChange(pagination.page + 1)}
className={pagination.page >= pagination.totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</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}
/>
<ExportDialog
open={exportDialogOpen}
onOpenChange={setExportDialogOpen}
keyword={keyword}
status={status}
departmentId={departmentId}
page={pagination.page}
pageSize={pagination.pageSize}
total={pagination.total}
/>
<ImportDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={handleSuccess}
/>
{/* 批量删除确认对话框 */}
<Dialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
确认批量删除
</DialogTitle>
<DialogDescription>
您确定要删除选中的 <span className="font-medium text-gray-900">{selectedIds.size}</span> 位用户吗?
<br />
此操作不可恢复,请谨慎操作。
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={() => setBatchDeleteDialogOpen(false)}>
取消
</Button>
<Button variant="destructive" onClick={handleBatchDeleteConfirm}>
确认删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
关键点解析:
- 使用
Set<string>存储选中的用户 ID,天然去重且查找效率高 toggleSelect函数切换单个行的选中状态toggleSelectAll函数切换当前页的全选状态- 选中状态使用浅蓝色背景高亮显示
- 批量删除按钮只在有选中项时显示
二、 创建批量删除 API
创建 src/app/api/users/batch-delete/route.ts:
typescript
// src/app/api/users/batch-delete/route.ts
import { NextResponse } from "next/server"
import prisma from "@/lib/prisma"
import { getServerSession } from "next-auth/next"
import { authOptions } from "../../auth/[...nextauth]/route"
// POST /api/users/batch-delete - 批量删除用户
export async function POST(request: Request) {
try {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json({ error: "未授权" }, { status: 401 })
}
const { ids } = await request.json()
if (!Array.isArray(ids) || ids.length === 0) {
return NextResponse.json({ error: "请选择要删除的用户" }, { status: 400 })
}
// 批量删除用户
const result = await prisma.user.deleteMany({
where: {
id: {
in: ids,
},
},
})
return NextResponse.json({
success: true,
deletedCount: result.count,
message: `成功删除 ${result.count} 位用户`,
})
} catch (error) {
console.error("批量删除用户失败:", error)
return NextResponse.json({ error: "批量删除失败" }, { status: 500 })
}
}
关键点解析:
- 使用 Prisma 的
deleteMany方法批量删除 - 通过
where: { id: { in: ids } }条件批量匹配 - 返回删除的数量,用于前端提示
三、 安装 Checkbox 组件
bash
npx shadcn@latest add checkbox
四、 运行效果
- 用户勾选需要删除的数据行(可跨页选择)
- 标题栏出现"批量删除 (N)"按钮
- 点击批量删除按钮,弹出确认对话框
- 确认后调用 /api/users/batch-delete API
- 删除成功,清空选中状态,刷新列表

总结
本章实现了用户列表的批量选择和批量删除功能:
- 表格复选框:使用 shadcn/ui 的 Checkbox 组件实现行选择
- 状态管理:使用 React 的 Set 状态管理选中 ID,支持跨分页保持
- 批量操作:实现了批量删除 API 和前端交互
- 用户体验:选中高亮、数量提示、确认对话框等细节优化
批量功能大大提升了数据管理效率,是企业管理系统的标准功能。