数据表格

管理后台中常见的会员列表。可以搜索筛选、点击列标题排序,并对每一行进行编辑或删除。

会员列表

要点

  1. 1

    排序交给库,筛选自己写

    点击标题时的排序由 TanStack Table 负责。搜索则先用 filter 决定“要显示哪些会员”,再把这个列表传给表格。

  2. 2

    编辑时“改副本,最后再替换”

    把正在编辑的会员的副本放进 state,在对话框里修改。只有点击“保存”时,才用 map 替换列表中的那一位会员。点击“取消”的话,列表不会变化。

  3. 3

    准备好 0 件时的显示

    没有符合搜索条件的会员时,不要显示只有标题的空表格,而是显示“没有符合条件的会员”。

使用的工具

点击查看第 2 部分的比较

修改代码

修改代码,右边的预览会跟着变化。

import { useState } from "react"
import {
  createColumnHelper,
  createSortedRowModel,
  rowSortingFeature,
  tableFeatures,
  useTable,
} from "@tanstack/react-table"
import { PencilIcon, PlusIcon, SearchIcon, Trash2Icon } from "lucide-react"
import { Button } from "./components/ui/button"
import { Input } from "./components/ui/input"
import { Label } from "./components/ui/label"
import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "./components/ui/dialog"

// 会员列表(真实项目中从服务器获取)
const initialMembers = [
  { id: 1, name: "王芳", email: "wang.fang@example.com", plan: "付费", joined: "2026-03-14" },
  { id: 2, name: "李强", email: "li.qiang@example.com", plan: "免费", joined: "2026-05-02" },
  { id: 3, name: "陈静", email: "chen.jing@example.com", plan: "付费", joined: "2025-11-20" },
  { id: 4, name: "刘洋", email: "liu.yang@example.com", plan: "免费", joined: "2026-08-09" },
  { id: 5, name: "赵丽", email: "zhao.li@example.com", plan: "付费", joined: "2026-01-27" },
  { id: 6, name: "孙浩", email: "sun.hao@example.com", plan: "免费", joined: "2026-09-18" },
]

// 要用的功能(点击列标题即可排序)
const features = tableFeatures({
  rowSortingFeature,
  sortedRowModel: createSortedRowModel(),
})

// 列的设置:每一列显示什么
const helper = createColumnHelper<typeof features>()
const columns = helper.columns([
  helper.accessor("name", { header: "姓名" }),
  helper.accessor("email", { header: "邮箱" }),
  helper.accessor("plan", { header: "套餐" }),
  helper.accessor("joined", { header: "注册日期" }),
])

export default function App() {
  // ① 会员列表(添加、删除、编辑时会变化),放在 state 里
  //const members = initialMembers
  const [members, setMembers] = useState(initialMembers)
  // ① 搜索文字(一开始是空的 ""),放在 state 里
  const [query, setQuery] = useState("")
  // 正在编辑的会员(不在编辑时为 null)。新增时,是一个 id 为 null 的空会员
  const [editing, setEditing] = useState(null)
  const isNew = editing !== null && editing.id === null

  // ③ 表格中显示的会员:只显示姓名或邮箱包含搜索文字(query)的会员
  const shown = members.filter((member) => member.name.includes(query) || member.email.includes(query))

  // const filtered = products.filter((product) => product.name.includes(query) && (category === "全部" || product.category === category))

  const table = useTable({ features, columns, data: shown })

  // 删除这个 id 的会员
  function removeMember(id) {
    // ⑤ 从 members 中删除这个 id 的会员
    setMembers(members.filter((member) => member.id !== id))
  }

  // 打开新增对话框(创建一个空会员,注册日期按今天 = 10/4 处理)
  function startAdd() {
    setEditing({ id: null, name: "", email: "", plan: "免费", joined: "2026-10-04" })
  }

  // 保存对话框内容(新增就加到列表末尾,编辑就只替换这一位)
  function saveMember() {
    if (isNew) {
      const newId = Math.max(0, ...members.map((member) => member.id)) + 1 // 现有最大的 id + 1
      // ⑧ 在 members 末尾加上新会员(editing 的内容,只把 id 换成 newId)
      setMembers([...members, { ...editing, id: newId }])

    } else {
      // ⑦ 只把 members 中与 editing 同一 id 的会员替换成 editing
      setMembers(members.map((member) => (member.id === editing.id ? { ...editing } : member)))
    }
    setEditing(null)
  }

  return (
    <div className="mx-auto my-6 flex w-full max-w-4xl flex-col gap-4 px-4 text-foreground">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <h1 className="text-xl font-semibold">
          会员列表 <span className="text-sm font-normal text-muted-foreground">(共 {shown.length} 人)</span>
        </h1>
        <div className="flex items-center gap-2">
          <div className="relative w-56">
            <SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
            {/* ② 把输入的文字放进 query(value 和 onChange) */}
            <Input value={query} onChange={(e) => setQuery(e.target.value)} className="pl-8" placeholder="按姓名或邮箱搜索" />
          </div>
          <Button onClick={startAdd}>
            <PlusIcon />
            新增会员
          </Button>
        </div>
      </div>

      <div className="overflow-hidden rounded-xl border bg-background">
        <table className="w-full text-sm">
          <thead>
            {table.getHeaderGroups().map((group) => (
              <tr key={group.id} className="bg-muted/50">
                {group.headers.map((header) => (
                  <th
                    key={header.id}
                    onClick={header.column.getToggleSortingHandler()}
                    className="cursor-pointer px-4 py-2.5 text-left font-medium text-muted-foreground select-none hover:text-foreground"
                  >
                    <table.FlexRender header={header} />
                    {header.column.getIsSorted() === "asc" && " ▲"}
                    {header.column.getIsSorted() === "desc" && " ▼"}
                  </th>
                ))}
                <th className="px-4 py-2.5 text-right font-medium text-muted-foreground">操作</th>
              </tr>
            ))}
          </thead>
          <tbody>
            {table.getRowModel().rows.map((row) => (
              <tr key={row.id} className="hover:bg-muted/30">
                {row.getAllCells().map((cell) => (
                  <td key={cell.id} className="border-t px-4 py-2.5">
                    <table.FlexRender cell={cell} />
                  </td>
                ))}
                <td className="border-t px-4 py-1.5 text-right whitespace-nowrap">
                  <Button variant="ghost" size="icon-sm" aria-label="编辑" className="text-muted-foreground" onClick={() => setEditing(row.original)}>
                    <PencilIcon />
                  </Button>
                  <Button variant="ghost" size="icon-sm" aria-label="删除" className="text-muted-foreground hover:bg-red-50 hover:text-red-600" onClick={() => removeMember(row.original.id)}>
                    <Trash2Icon />
                  </Button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
        {/* ④ 只有在没有要显示的会员时,才显示下面的文字 */}
        {shown.length === 0 && (
          <p className="border-t py-10 text-center text-sm text-muted-foreground">没有符合条件的会员</p>
        )}
      </div>
      <p className="text-xs text-muted-foreground">点击列标题即可排序</p>

      {/* 新增/编辑对话框(只在 editing 中有会员时打开) */}
      <Dialog open={editing !== null} onOpenChange={(isOpen) => !isOpen && setEditing(null)}>
        <DialogContent className="p-6">
          <DialogHeader>
            <DialogTitle className="text-lg font-semibold">{isNew ? "新增会员" : "编辑会员"}</DialogTitle>
          </DialogHeader>
          {editing && (
            <div className="flex flex-col gap-4">
              <div className="flex flex-col gap-2">
                <Label htmlFor="name">姓名</Label>
                {/* ⑥ 用输入的文字只修改 editing 的 name(onChange) */}
                <Input id="name" value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} />
              </div>
              <div className="flex flex-col gap-2">
                <Label htmlFor="email">邮箱</Label>
                {/* ⑥ 用输入的文字只修改 editing 的 email(onChange) */}
                <Input id="email" value={editing.email} onChange={(e) => setEditing({ ...editing, email: e.target.value })} />
              </div>
            </div>
          )}
          <DialogFooter className="mx-0 mb-0 border-t-0 bg-transparent p-0 pt-2">
            <DialogClose asChild>
              <Button variant="outline">取消</Button>
            </DialogClose>
            <Button disabled={editing === null || editing.name === "" || editing.email === ""} onClick={saveMember}>
              {isNew ? "添加" : "保存"}
            </Button>
          </DialogFooter>
        </DialogContent>
      </Dialog>
    </div>
  )
}