数据表格
管理后台中常见的会员列表。可以搜索筛选、点击列标题排序,并对每一行进行编辑或删除。
会员列表
要点
- 1
排序交给库,筛选自己写
点击标题时的排序由 TanStack Table 负责。搜索则先用 filter 决定“要显示哪些会员”,再把这个列表传给表格。
- 2
编辑时“改副本,最后再替换”
把正在编辑的会员的副本放进 state,在对话框里修改。只有点击“保存”时,才用 map 替换列表中的那一位会员。点击“取消”的话,列表不会变化。
- 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> ) }