数据表格

这个示例把表格常见状态放进 URL:关键词、状态筛选、排序字段、排序方向、分页位置和每页数量。它不依赖表格逻辑库,重点展示 URL search params 如何驱动一个组合型列表视图。

示例数据基于 Rick and Morty API 的 Character schema 准备为本地 mock,不会在运行时请求接口。

分页状态由内置的 useSearchPagination() 管理。SWR 返回与当前查询匹配的结果后,示例把其中的 total 交给 preventOverflow,修正超出最大页码的 page

点击 Page 999,或者手动把 URL 中的 page 改成一个很大的数字,可以观察请求完成后的页码修正。这个按钮只用于演示:真实应用的分页组件已知 pageCount 时,应在调用 setPage 前限制目标页码,不应主动写入一个已知越界的值。

快速重复设置 page=999 时,SWR 可能对相同请求执行 dedupe,成功回调不会再次触发,因而这一次无法自动修正页码。这也说明 preventOverflow 是收到可信 total 后的一次恢复操作,不负责协调请求缓存;更多背景见理解分页越界

Gender
Rows per page
location.search(empty)
keyword: -statuses: -sortBy: idorder: ascpage: 1pageSize: 10
import { useSearchValues } from '@guanriyue/decurl';
import { defineFields, field } from '@guanriyue/decurl/codec';
import {
  elementOf,
  length,
  mapItems,
  pipe,
  unique,
} from '@guanriyue/decurl/decode';
import { useSearchPagination } from '@guanriyue/decurl/pagination';
import {
  ArrowDown,
  ArrowUp,
  ChevronsUpDown,
  Filter,
  FlaskConical,
  LoaderCircle,
  RotateCcw,
  Search,
} from 'lucide-react';
import { useLocation } from 'react-router';
import useSWR from 'swr';

import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
  DropdownMenu,
  DropdownMenuCheckboxItem,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from '@/components/ui/pagination';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import {
  type CharacterSortKey,
  type CharacterStatus,
  fetchRickAndMortyCharacters,
} from '@/data/rick-and-morty-characters';

const statusOptions = ['Alive', 'Dead', 'unknown'] as const;
const skeletonRows = Array.from({ length: 10 }, (_, index) => index);

const fields = defineFields({
  keyword: field({
    name: 'keyword',
    decode: pipe(length.min(1)),
  }),
  statuses: field({
    name: 'status',
    mode: 'multi',
    decode: pipe(mapItems(elementOf(statusOptions)), unique),
  }),
  sortBy: field({
    name: 'sort',
    decode: elementOf(['id', 'name', 'status', 'species', 'location']),
    defaultValue: 'id',
  }),
  order: field({
    name: 'order',
    decode: elementOf(['asc', 'desc']),
    defaultValue: 'asc',
  }),
});

const toSearchText = (search: string): string => {
  return search.length > 0 ? search : '(empty)';
};

const getSortIcon = (
  sortBy: string,
  order: string,
  column: CharacterSortKey,
) => {
  if (sortBy !== column) {
    return <ChevronsUpDown />;
  }

  return order === 'asc' ? <ArrowUp /> : <ArrowDown />;
};

const getNextStatuses = (
  statuses: CharacterStatus[] | undefined,
  status: CharacterStatus,
  checked: boolean,
) => {
  const nextStatuses = new Set(statuses ?? []);

  if (checked) {
    nextStatuses.add(status);
  } else {
    nextStatuses.delete(status);
  }

  const nextList = Array.from(nextStatuses);
  return nextList.length > 0 ? nextList : undefined;
};

const getVisiblePages = (page: number, pageCount: number): number[] => {
  const start = Math.max(1, page - 1);
  const end = Math.min(pageCount, page + 1);

  return Array.from({ length: end - start + 1 }, (_, index) => start + index);
};

const DataTable = () => {
  const location = useLocation();
  const [values, setValues] = useSearchValues(fields);
  const pagination = useSearchPagination();
  const {
    data: result,
    isLoading,
    isValidating,
  } = useSWR(
    [
      'rick-and-morty-characters',
      values.keyword ?? '',
      values.statuses?.join('|') ?? '',
      values.sortBy,
      values.order,
      pagination.page,
      pagination.pageSize,
    ],
    () =>
      fetchRickAndMortyCharacters({
        ...values,
        page: pagination.page,
        pageSize: pagination.pageSize,
      }),
    {
      keepPreviousData: true,
      onSuccess: (nextResult) => {
        pagination.preventOverflow(nextResult);
      },
    },
  );
  const showSkeleton = isLoading && !result;
  const isUpdating = isValidating && !!result;
  const pageCount = result?.pageCount ?? 1;
  const page = Math.min(Math.max(pagination.page, 1), pageCount);
  const visiblePages = getVisiblePages(page, pageCount);

  const updateSort = (column: CharacterSortKey) => {
    setValues({
      sortBy: column,
      order:
        values.sortBy === column && values.order === 'asc' ? 'desc' : 'asc',
    });
    pagination.resetPage();
  };

  const updatePage = (nextPage: number) => {
    pagination.setPage(Math.min(Math.max(nextPage, 1), pageCount));
  };

  return (
    <section className="mx-auto w-full max-w-6xl space-y-6 p-4">
      <div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
        <div className="relative min-w-0 lg:w-80">
          <Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
          <Input
            className="pl-9"
            placeholder="Filter name, species, location..."
            value={values.keyword ?? ''}
            onChange={(event) => {
              const keyword = event.currentTarget.value;

              setValues({
                keyword: keyword === '' ? undefined : keyword,
              });
              pagination.resetPage();
            }}
          />
        </div>

        <div className="flex flex-wrap gap-2">
          {isUpdating && (
            <Badge variant="secondary" className="gap-1.5">
              <LoaderCircle className="size-3 animate-spin" />
              Updating
            </Badge>
          )}

          <DropdownMenu>
            <DropdownMenuTrigger asChild>
              <Button type="button" variant="outline">
                <Filter />
                Status
                {values.statuses && (
                  <Badge variant="secondary">{values.statuses.length}</Badge>
                )}
              </Button>
            </DropdownMenuTrigger>
            <DropdownMenuContent align="end">
              <DropdownMenuLabel>Status filter</DropdownMenuLabel>
              <DropdownMenuSeparator />
              {statusOptions.map((status) => (
                <DropdownMenuCheckboxItem
                  key={status}
                  checked={
                    values.statuses?.some((value) => value === status) ?? false
                  }
                  onCheckedChange={(checked) => {
                    setValues({
                      statuses: getNextStatuses(
                        values.statuses,
                        status,
                        checked === true,
                      ),
                    });
                    pagination.resetPage();
                  }}
                >
                  {status}
                </DropdownMenuCheckboxItem>
              ))}
            </DropdownMenuContent>
          </DropdownMenu>

          <Button
            type="button"
            variant="outline"
            onClick={() => {
              pagination.setPage(999);
            }}
          >
            <FlaskConical />
            Page 999
          </Button>

          <Button
            type="button"
            variant="outline"
            onClick={() => {
              setValues({
                keyword: undefined,
                statuses: undefined,
                sortBy: undefined,
                order: undefined,
              });
              pagination.setPagination({
                page: undefined,
                pageSize: undefined,
              });
            }}
          >
            <RotateCcw />
            Reset
          </Button>
        </div>
      </div>

      <Table className="min-w-[52rem] table-fixed">
        <TableHeader>
          <TableRow>
            <TableHead className="w-16">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="-ml-3"
                onClick={() => {
                  updateSort('id');
                }}
              >
                ID
                {getSortIcon(values.sortBy, values.order, 'id')}
              </Button>
            </TableHead>
            <TableHead className="w-56">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="-ml-3"
                onClick={() => {
                  updateSort('name');
                }}
              >
                Name
                {getSortIcon(values.sortBy, values.order, 'name')}
              </Button>
            </TableHead>
            <TableHead className="w-32">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="-ml-3"
                onClick={() => {
                  updateSort('status');
                }}
              >
                Status
                {getSortIcon(values.sortBy, values.order, 'status')}
              </Button>
            </TableHead>
            <TableHead className="w-36">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="-ml-3"
                onClick={() => {
                  updateSort('species');
                }}
              >
                Species
                {getSortIcon(values.sortBy, values.order, 'species')}
              </Button>
            </TableHead>
            <TableHead className="w-32">Gender</TableHead>
            <TableHead className="w-64">
              <Button
                type="button"
                variant="ghost"
                size="sm"
                className="-ml-3"
                onClick={() => {
                  updateSort('location');
                }}
              >
                Location
                {getSortIcon(values.sortBy, values.order, 'location')}
              </Button>
            </TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {showSkeleton &&
            skeletonRows.map((row) => (
              <TableRow key={row}>
                <TableCell className="w-16">
                  <Skeleton className="h-4 w-8" />
                </TableCell>
                <TableCell className="w-56">
                  <Skeleton className="h-4 w-36" />
                </TableCell>
                <TableCell className="w-32">
                  <Skeleton className="h-5 w-16 rounded-full" />
                </TableCell>
                <TableCell className="w-36">
                  <Skeleton className="h-4 w-20" />
                </TableCell>
                <TableCell className="w-32">
                  <Skeleton className="h-4 w-16" />
                </TableCell>
                <TableCell className="w-64">
                  <Skeleton className="h-4 w-40" />
                </TableCell>
              </TableRow>
            ))}

          {!showSkeleton &&
            result?.rows.map((row) => (
              <TableRow key={row.id}>
                <TableCell className="w-16 font-mono text-xs">
                  {row.id}
                </TableCell>
                <TableCell className="w-56 truncate font-medium">
                  {row.name}
                </TableCell>
                <TableCell className="w-32">
                  <Badge variant="outline">{row.status}</Badge>
                </TableCell>
                <TableCell className="w-36 truncate">{row.species}</TableCell>
                <TableCell className="w-32 truncate">{row.gender}</TableCell>
                <TableCell className="w-64 truncate">{row.location}</TableCell>
              </TableRow>
            ))}
          {!showSkeleton && result?.rows.length === 0 && (
            <TableRow>
              <TableCell
                colSpan={6}
                className="h-24 text-center text-muted-foreground"
              >
                No results.
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>

      <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
        {showSkeleton ? (
          <Skeleton className="h-5 w-56" />
        ) : (
          <p className="text-sm text-muted-foreground">
            Showing {result?.rangeStart}-{result?.rangeEnd} of {result?.total}{' '}
            characters
          </p>
        )}

        <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-end">
          <div className="flex items-center gap-2">
            <span className="text-sm text-muted-foreground">Rows per page</span>
            <Select
              value={String(pagination.pageSize)}
              onValueChange={(value) => {
                const pageSize = Number(value);

                if (!useSearchPagination.pageSizeOptions.includes(pageSize)) {
                  return;
                }

                pagination.setPageSize(pageSize);
              }}
            >
              <SelectTrigger size="sm" className="w-20">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {useSearchPagination.pageSizeOptions.map((pageSize) => (
                  <SelectItem key={pageSize} value={String(pageSize)}>
                    {pageSize}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>

          <Pagination className="mx-0 w-auto justify-start">
            <PaginationContent>
              <PaginationItem>
                <PaginationPrevious
                  href="#"
                  aria-disabled={page <= 1}
                  tabIndex={page <= 1 ? -1 : undefined}
                  className={
                    isLoading || page <= 1
                      ? 'pointer-events-none opacity-50'
                      : undefined
                  }
                  onClick={(event) => {
                    event.preventDefault();
                    updatePage(page - 1);
                  }}
                />
              </PaginationItem>

              {visiblePages[0] > 1 && (
                <>
                  <PaginationItem>
                    <PaginationLink
                      href="#"
                      onClick={(event) => {
                        event.preventDefault();
                        updatePage(1);
                      }}
                    >
                      1
                    </PaginationLink>
                  </PaginationItem>
                  {visiblePages[0] > 2 && (
                    <PaginationItem>
                      <PaginationEllipsis />
                    </PaginationItem>
                  )}
                </>
              )}

              {visiblePages.map((visiblePage) => (
                <PaginationItem key={visiblePage}>
                  <PaginationLink
                    href="#"
                    isActive={visiblePage === page}
                    onClick={(event) => {
                      event.preventDefault();
                      updatePage(visiblePage);
                    }}
                  >
                    {visiblePage}
                  </PaginationLink>
                </PaginationItem>
              ))}

              {visiblePages[visiblePages.length - 1] < pageCount && (
                <>
                  {visiblePages[visiblePages.length - 1] < pageCount - 1 && (
                    <PaginationItem>
                      <PaginationEllipsis />
                    </PaginationItem>
                  )}
                  <PaginationItem>
                    <PaginationLink
                      href="#"
                      onClick={(event) => {
                        event.preventDefault();
                        updatePage(pageCount);
                      }}
                    >
                      {pageCount}
                    </PaginationLink>
                  </PaginationItem>
                </>
              )}

              <PaginationItem>
                <PaginationNext
                  href="#"
                  aria-disabled={page >= pageCount}
                  tabIndex={page >= pageCount ? -1 : undefined}
                  className={
                    isLoading || page >= pageCount
                      ? 'pointer-events-none opacity-50'
                      : undefined
                  }
                  onClick={(event) => {
                    event.preventDefault();
                    updatePage(page + 1);
                  }}
                />
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        </div>
      </div>

      <div className="space-y-4 rounded-md bg-muted/40 p-4 text-sm">
        <div className="space-y-2">
          <span className="font-medium">location.search</span>
          <code className="block break-all rounded-md bg-background/70 px-2.5 py-2 font-mono text-xs">
            {toSearchText(location.search)}
          </code>
        </div>
        <div className="flex flex-wrap gap-2">
          <Badge variant="secondary">keyword: {values.keyword ?? '-'}</Badge>
          <Badge variant="secondary">
            statuses: {values.statuses?.join(', ') || '-'}
          </Badge>
          <Badge variant="secondary">sortBy: {values.sortBy}</Badge>
          <Badge variant="secondary">order: {values.order}</Badge>
          <Badge variant="secondary">page: {pagination.page}</Badge>
          <Badge variant="secondary">pageSize: {pagination.pageSize}</Badge>
        </div>
      </div>
    </section>
  );
};

export default DataTable;

相关 API