纯代码实战:MBA培训管理系统 (十四) ——用户管理(批量选择与批量删除)

目录

  • 前言
  • [一、 更新用户列表组件](#一、 更新用户列表组件)
  • [二、 创建批量删除 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

四、 运行效果

  1. 用户勾选需要删除的数据行(可跨页选择)
  2. 标题栏出现"批量删除 (N)"按钮
  3. 点击批量删除按钮,弹出确认对话框
  4. 确认后调用 /api/users/batch-delete API
  5. 删除成功,清空选中状态,刷新列表

总结

本章实现了用户列表的批量选择和批量删除功能:

  1. 表格复选框:使用 shadcn/ui 的 Checkbox 组件实现行选择
  2. 状态管理:使用 React 的 Set 状态管理选中 ID,支持跨分页保持
  3. 批量操作:实现了批量删除 API 和前端交互
  4. 用户体验:选中高亮、数量提示、确认对话框等细节优化

批量功能大大提升了数据管理效率,是企业管理系统的标准功能。

相关推荐
九皇叔叔6 小时前
003-SpringSecurity-Demo 统一响应类
java·javascript·spring·springsecurity
Hello--_--World8 小时前
JavaScript运行机制、v8原理、js事件循环
开发语言·javascript·ecmascript
敲敲了个代码12 小时前
React 那么多状态管理库,到底选哪个?如果非要焊死一个呢?这篇文章解决你的选择困难症
前端·javascript·学习·react.js·前端框架
打瞌睡的朱尤12 小时前
js复习--考核
开发语言·前端·javascript
前端极客探险家12 小时前
React 全面入门与进阶实战教程
前端·javascript·react.js
程序员 沐阳13 小时前
异步编程深潜:事件循环、Promise 与 async/await 的底层真相
javascript
276695829213 小时前
zp_stoken 算法风控分析
java·前端·javascript·python·web逆向·boss直聘·zp_stoken
叫我一声阿雷吧13 小时前
JS 入门通关手册(38):防抖与节流 原理 + 手写 + 实战场景(面试必考)
javascript·性能优化·前端面试·防抖·节流·js手写题
妮妮喔妮13 小时前
组件的封装
开发语言·前端·javascript