Chapter 08

数据展示

从基础 Table 到 TanStack Table 驱动的 DataTable,实现排序/筛选/分页/行选择,并配上 Badge/Avatar/Skeleton/Toast。

8.1 关键概念

Table(静态表格)
shadcn 的 Table 只是一套带样式的语义标签(Table/TableHeader/TableBody/TableRow/TableCell),无逻辑,适合展示固定数据。
TanStack Table (v8)
Headless 表格逻辑库,管理列定义、排序、筛选、分页、行选择等"状态与算法",不渲染 UI——UI 由你用 shadcn Table 画。
ColumnDef(列定义)
描述每一列:取哪个字段(accessorKey)、表头怎么渲染(header)、单元格怎么渲染(cell)。
useReactTable
把数据 + 列定义 + 各种"row model"(排序/筛选/分页)组装成 table 实例,供 UI 遍历渲染。
flexRender
TanStack 的渲染助手,能渲染 header/cell 里既可能是字符串也可能是 JSX 的定义。
Badge / Avatar
Badge 是状态标签(成功/警告等,含 variant);Avatar 是头像(图片 + 加载失败的文字回退 AvatarFallback)。
Skeleton
加载占位骨架屏,数据未到时显示灰块动画,避免布局跳动与空白焦虑。
Toast(sonner)
轻量通知条。shadcn 现推荐 sonner:放一个 <Toaster />,任意处调用 toast("已保存") 即弹出。

8.2 安装

$ npx shadcn@latest add table badge avatar skeleton sonner
$ npm i @tanstack/react-table

8.3 静态 Table

import {
  Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from "@/components/ui/table";

<Table>
  <TableHeader>
    <TableRow>
      <TableHead>发票</TableHead><TableHead>状态</TableHead>
      <TableHead className="text-right">金额</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell>INV001</TableCell>
      <TableCell><Badge>已付</Badge></TableCell>
      <TableCell className="text-right">¥250.00</TableCell>
    </TableRow>
  </TableBody>
</Table>

8.4 定义列(ColumnDef)

// columns.tsx
"use client";
import { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@/components/ui/badge";

export type Payment = {
  id: string; amount: number;
  status: "pending" | "success" | "failed"; email: string;
};

export const columns: ColumnDef<Payment>[] = [
  { accessorKey: "status", header: "状态",
    cell: ({ row }) => <Badge>{row.getValue("status")}</Badge> },
  { accessorKey: "email", header: "邮箱" },
  { accessorKey: "amount", header: () => <div className="text-right">金额</div>,
    cell: ({ row }) => {
      const amount = parseFloat(row.getValue("amount"));
      return <div className="text-right">¥{amount.toFixed(2)}</div>;
    } },
];

8.5 DataTable:接入 TanStack

// data-table.tsx
"use client";
import {
  useReactTable, getCoreRowModel, getSortedRowModel,
  getFilteredRowModel, getPaginationRowModel,
  flexRender, type SortingState, type ColumnFiltersState,
} from "@tanstack/react-table";
import { useState } from "react";

export function DataTable<TData, TValue>({ columns, data }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [filters, setFilters] = useState<ColumnFiltersState>([]);

  const table = useReactTable({
    data, columns,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    onSortingChange: setSorting,
    onColumnFiltersChange: setFilters,
    state: { sorting, columnFilters: filters },
  });

  return (
    <Table>
      <TableHeader>
        {table.getHeaderGroups().map((hg) => (
          <TableRow key={hg.id}>
            {hg.headers.map((h) => (
              <TableHead key={h.id}>
                {flexRender(h.column.columnDef.header, h.getContext())}
              </TableHead>
            ))}
          </TableRow>
        ))}
      </TableHeader>
      <TableBody>
        {table.getRowModel().rows.map((row) => (
          <TableRow key={row.id}>
            {row.getVisibleCells().map((cell) => (
              <TableCell key={cell.id}>
                {flexRender(cell.column.columnDef.cell, cell.getContext())}
              </TableCell>
            ))}
          </TableRow>
        ))}
      </TableBody>
    </Table>
  );
}

8.6 可排序表头

// 在 ColumnDef 里让 header 变成可点击排序按钮
{
  accessorKey: "amount",
  header: ({ column }) => (
    <Button variant="ghost"
      onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}>
      金额 <ArrowUpDown className="ml-2 h-4 w-4" />
    </Button>
  ),
}

8.7 筛选与分页控件

// 按 email 筛选
<Input placeholder="筛选邮箱…"
  value={(table.getColumn("email")?.getFilterValue() as string) ?? ""}
  onChange={(e) => table.getColumn("email")?.setFilterValue(e.target.value)}
  className="max-w-sm" />

// 分页按钮
<div className="flex justify-end gap-2 py-4">
  <Button variant="outline" size="sm"
    onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>上一页</Button>
  <Button variant="outline" size="sm"
    onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>下一页</Button>
</div>
DataTable 是"代码配方"

shadcn 官网的 DataTable 文档不是一个组件,而是一段可复制的组合示例。你把它抄进项目后自由拆分、加行选择/列显隐/服务端分页——因为代码归你所有。

8.8 Badge / Avatar / Skeleton

import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";

<Badge>默认</Badge>
<Badge variant="secondary">次要</Badge>
<Badge variant="destructive">错误</Badge>
<Badge variant="outline">描边</Badge>

<Avatar>
  <AvatarImage src="/u.png" alt="@user" />
  <AvatarFallback>CN</AvatarFallback>
</Avatar>

// 加载态骨架
<div className="flex items-center gap-4">
  <Skeleton className="h-10 w-10 rounded-full" />
  <Skeleton className="h-4 w-[200px]" />
</div>

8.9 Toast(sonner)

// app/layout.tsx —— 放一次 Toaster
import { Toaster } from "@/components/ui/sonner";
<body>{children}<Toaster /></body>

// 任意组件里调用
import { toast } from "sonner";

<Button onClick={() =>
  toast("发票已创建", {
    description: "2026-07-07 周一",
    action: { label: "撤销", onClick: () => {} },
  })
}>创建</Button>

// 语义变体
toast.success("保存成功");
toast.error("提交失败");

8.10 小结与下一步