資料表格

管理後台常見的會員列表。可以用搜尋篩選、點欄位標題排序,並對每一列進行編輯或刪除。

會員列表

重點

  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: "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>
  )
}