資料表格
管理後台常見的會員列表。可以用搜尋篩選、點欄位標題排序,並對每一列進行編輯或刪除。
會員列表
重點
- 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: "yijun.chen@example.com", plan: "付費", joined: "2026-03-14" }, { id: 2, name: "林志明", email: "zhiming.lin@example.com", plan: "免費", joined: "2026-05-02" }, { id: 3, name: "王雅婷", email: "yating.wang@example.com", plan: "付費", joined: "2025-11-20" }, { id: 4, name: "張家豪", email: "jiahao.chang@example.com", plan: "免費", joined: "2026-08-09" }, { id: 5, name: "李佳穎", email: "chiaying.li@example.com", plan: "付費", joined: "2026-01-27" }, { id: 6, name: "黃冠宇", email: "kuanyu.huang@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> ) }