Communitygithub.com

0necontroller/skills

设计系统、布局架构、微渐变统计卡片、可折叠侧边栏、面包屑导航、日期筛选器和 TanStack 数据表格,用于现代仪表盘。

skills 是什么?

该技能为使用 Next.js (App Router)、Tailwind CSS (v3 或 v4) 和 shadcn/ui 构建高端、响应式管理及分析仪表盘提供了全面的指南、组件架构和可直接复制粘贴的实现模式。它涵盖了仪表盘开发的完整流程,包括必备依赖的自动安装检查、统一的主题令牌系统、可折叠的 admin 布局与侧边栏、带面包屑导航的页面标题层级、微渐变统计卡片、日期筛选器以及基于 TanStack Table 的复杂数据表格。其核心价值在于确保所有组件遵循一致的设计原则,并利用了 shadcn/ui 的最新最佳实践,从而显著提升开发效率和最终产品的视觉与交互品质。该技能适用于 Claude Code、Cursor 和 Codex 等 AI 编码代理。

兼容平台~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/0necontroller/skills/tree/HEAD/skills/dashboard-ui

在你喜欢的 AI 中提问

打开一个已预加载此 Agent Skill 的新对话。

文档

仪表盘 UI 技能

使用 Next.js (App Router)、Tailwind CSS (v3 或 v4) 和 shadcn/ui 构建高端、响应式管理及分析仪表盘的全面指南、组件架构和可直接复制粘贴的实现模式。


1. 先决条件与 shadcn/ui 组件安装

在生成或修改任何仪表盘组件之前,AI 代理必须执行以下前提检查:

步骤 1:验证 shadcn/ui 配置 (components.json)

检查项目根目录下是否存在 components.json:

  • 如果缺少 components.json:不要盲目尝试写入或安装组件。提示用户:

    "该项目中未找到 shadcn/ui 配置 (components.json)。请先初始化 shadcn/ui(例如 npx shadcn@latest init),以便正确配置组件目录和路径别名。"

  • 如果 components.json 存在:继续进行组件验证。

步骤 2:安装所需的 shadcn/ui 组件

检查所需的原始组件是否存在于您指定的 UI 组件目录中(例如 @/components/ui/)。对于任何缺失的组件,运行 npx shadcn@latest add <component>:

# 核心布局与侧边栏
npx shadcn@latest add sidebar collapsible separator sheet tooltip avatar dropdown-menu

# 页面标题与面包屑
npx shadcn@latest add breadcrumb badge button

# 统计与分析卡片
npx shadcn@latest add card skeleton

# 日期筛选器
npx shadcn@latest add popover calendar

# 数据表格与表单控件
npx shadcn@latest add table input select

或者在一个命令中安装所有仪表盘必需组件:

npx shadcn@latest add sidebar collapsible separator sheet tooltip avatar dropdown-menu breadcrumb badge button card skeleton popover calendar table input select

步骤 3:验证并安装核心 npm 包

此技能中的所有数据表格均使用 TanStack Table (@tanstack/react-table) 与 shadcn/ui <Table> 组合构建。确保 package.json 中存在以下包:

  • @tanstack/react-table:表格状态管理、列定义、分页和排序。
  • date-fns:日期解析、格式化和日期筛选器的算术运算。
  • lucide-react:标准图标集。

如果缺失,请安装它们:

npm install @tanstack/react-table date-fns lucide-react
# 或:pnpm add @tanstack/react-table date-fns lucide-react
# 或:bun add @tanstack/react-table date-fns lucide-react

2. 设计系统与主题基础

所有仪表盘组件都依赖于语义化的 CSS 变量和微妙、高对比度的视觉令牌。

主题令牌 (globals.css)

:root {
  --background: 0 0% 100%;
  --foreground: 0 0% 3.9%;
  --card: 0 0% 100%;
  --card-foreground: 0 0% 3.9%;
  --popover: 0 0% 100%;
  --popover-foreground: 0 0% 3.9%;
  --primary: 330 43% 46%;
  --primary-foreground: 0 0% 98%;
  --muted: 0 0% 96.1%;
  --muted-foreground: 0 0% 45.1%;
  --border: 0 0% 89.8%;
  --radius: 0.5rem;

  /* 侧边栏令牌 */
  --sidebar: hsl(0 0% 98%);
  --sidebar-foreground: hsl(240 5.3% 26.1%);
  --sidebar-primary: hsl(240 5.9% 10%);
  --sidebar-primary-foreground: hsl(0 0% 98%);
  --sidebar-accent: hsl(240 4.8% 95.9%);
  --sidebar-accent-foreground: hsl(240 5.9% 10%);
  --sidebar-border: hsl(220 13% 91%);
  --sidebar-ring: hsl(217.2 91.2% 59.8%);
}

.dark {
  --background: 0 0% 3.9%;
  --foreground: 0 0% 98%;
  --card: 0 0% 3.9%;
  --card-foreground: 0 0% 98%;
  --muted: 0 0% 14.9%;
  --muted-foreground: 0 0% 63.9%;
  --border: 0 0% 14.9%;

  /* 深色侧边栏令牌 */
  --sidebar: hsl(240 5.9% 10%);
  --sidebar-foreground: hsl(240 4.8% 95.9%);
  --sidebar-primary: hsl(224.3 76.3% 48%);
  --sidebar-accent: hsl(240 3.7% 15.9%);
  --sidebar-accent-foreground: hsl(240 4.8% 95.9%);
  --sidebar-border: hsl(240 3.7% 15.9%);
}

3. 管理布局与侧边栏架构

2.1 入口点布局 (app/admin/layout.tsx)

根管理布局将页面包裹在 SidebarProvider 中,插入可折叠的 AppSidebar,并通过移动端触发器控制 header。

import { AppSidebar } from '@/components/admin/sidebar/app-sidebar';
import {
  SidebarInset,
  SidebarProvider,
  SidebarTrigger
} from '@/components/ui/sidebar';

export default function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <SidebarProvider>
      <AppSidebar />
      <SidebarInset>
        {/* Mobile Header: fixed top blur on small screens, transparent on desktop */}
        <header className="bg-background/95 fixed top-0 right-0 left-0 z-20 flex h-16 shrink-0 items-center gap-2 border-b shadow-xs backdrop-blur transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12 md:static md:border-none md:bg-transparent md:shadow-none">
          <div className="flex w-full items-center justify-between gap-2 px-4">
            <SidebarTrigger className="-ml-1" />
            <div className="font-semibold text-sm tracking-tight md:hidden">
              Admin Portal
            </div>
            <div className="w-8 md:hidden" />
          </div>
        </header>

        {/* Main Content Area */}
        <main className="flex flex-1 flex-col gap-4 p-4 pt-20 md:pt-0">
          {children}
        </main>
      </SidebarInset>
    </SidebarProvider>
  );
}

2.2 应用侧边栏 (components/admin/sidebar/app-sidebar.tsx)

使用 variant="inset" 和 collapsible="icon",可折叠为仅图标的 dock。

'use client';

import {
  PieChart,
  ShoppingBag,
  Users,
  Settings2,
  Package
} from 'lucide-react';
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarHeader,
  SidebarRail
} from '@/components/ui/sidebar';
import { NavMain } from './nav-main';
import { NavUser } from './nav-user';
import { TeamSwitcher } from './team-switcher';

const navData = [
  {
    title: 'Dashboard',
    url: '/admin',
    icon: PieChart,
    items: [{ title: 'Overview', url: '/admin' }]
  },
  {
    title: 'Orders',
    url: '/admin/orders',
    icon: ShoppingBag,
    items: [
      { title: 'All Orders', url: '/admin/orders' },
      { title: 'Transactions', url: '/admin/orders/transactions' }
    ]
  },
  {
    title: 'Inventory',
    url: '/admin/inventory',
    icon: Package,
    items: [
      { title: 'Products', url: '/admin/inventory/products' },
      { title: 'Low Stock', url: '/admin/inventory/low-stock' }
    ]
  },
  {
    title: 'Users',
    url: '/admin/users',
    icon: Users,
    items: [{ title: 'Customers', url: '/admin/users' }]
  },
  {
    title: 'Settings',
    url: '/admin/settings',
    icon: Settings2,
    items: [{ title: 'General', url: '/admin/settings' }]
  }
];

export function AppSidebar(props: React.ComponentProps<typeof Sidebar>) {
  return (
    <Sidebar variant="inset" collapsible="icon" {...props}>
      <SidebarHeader>
        <TeamSwitcher />
      </SidebarHeader>
      <SidebarContent>
        <NavMain items={navData} />
      </SidebarContent>
      <SidebarFooter>
        <NavUser user={{ name: 'Admin', email: 'admin@company.com' }} />
      </SidebarFooter>
      <SidebarRail />
    </Sidebar>
  );
}

2.3 可折叠导航,支持激活状态和计数药丸 (nav-main.tsx)

高亮激活链接,平滑旋转 chevron 图标,并使用 tabular-nums 显示通知计数。

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ChevronRight, type LucideIcon } from 'lucide-react';
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger
} from '@/components/ui/collapsible';
import {
  SidebarGroup,
  SidebarGroupLabel,
  SidebarMenu,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarMenuSub,
  SidebarMenuSubButton,
  SidebarMenuSubItem
} from '@/components/ui/sidebar';

interface NavItem {
  title: string;
  url: string;
  icon?: LucideIcon;
  count?: number;
  items?: { title: string; url: string; count?: number }[];
}

export function NavMain({ items }: { items: NavItem[] }) {
  const pathname = usePathname();

  const isPathActive = (target: string) => {
    if (!target) return false;
    if (pathname === target) return true;
    if (target === '/admin') return false;
    return pathname.startsWith(`${target}/`);
  };

  return (
    <SidebarGroup>
      <SidebarGroupLabel>Platform</SidebarGroupLabel>
      <SidebarMenu>
        {items.map((item) => {
          const subItemActive = item.items?.some((s) => isPathActive(s.url));
          const isActive = subItemActive || isPathActive(item.url);

          return (
            <Collapsible
              key={item.title}
              asChild
              defaultOpen={isActive}
              className="group/collapsible"
            >
              <SidebarMenuItem>
                <CollapsibleTrigger asChild>
                  <SidebarMenuButton tooltip={item.title} isActive={isActive}>
                    {item.icon && <item.icon />}
                    <span>{item.title}</span>
                    {item.count && item.count > 0 && (
                      <span className="bg-primary/10 text-primary mr-1 ml-auto flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold tabular-nums">
                        {item.count}
                      </span>
                    )}
                    <ChevronRight
                      className={`transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90 ${
                        item.count ? '' : 'ml-auto'
                      }`}
                    />
                  </SidebarMenuButton>
                </CollapsibleTrigger>
                <CollapsibleContent>
                  <SidebarMenuSub>
                    {item.items?.map((subItem) => {
                      const subIsActive = isPathActive(subItem.url);
                      return (
                        <SidebarMenuSubItem key={subItem.title}>
                          <SidebarMenuSubButton asChild isActive={subIsActive}>
                            <Link
                              href={subItem.url}
                              className="flex w-full items-center justify-between"
                            >
                              <span>{subItem.title}</span>
                              {subItem.count && subItem.count > 0 && (
                                <span className="bg-primary ml-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium text-white tabular-nums">
                                  {subItem.count}
                                </span>
                              )}
                            </Link>
                          </SidebarMenuSubButton>
                        </SidebarMenuSubItem>
                      );
                    })}
                  </SidebarMenuSub>
                </CollapsibleContent>
              </SidebarMenuItem>
            </Collapsible>
          );
        })}
      </SidebarMenu>
    </SidebarGroup>
  );
}

4. 页面标题、面包屑和标题层级

每个页面都以一个清晰的标题行开始,该行配对了层级面包屑、一个无障碍 <h1> 标题、副标题和操作/筛选工具栏。

import Link from 'next/link';
import {
  Breadcrumb,
  BreadcrumbItem,
  BreadcrumbLink,
  BreadcrumbList,
  BreadcrumbPage,
  BreadcrumbSeparator
} from '@/components/ui/breadcrumb';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';

interface PageHeaderProps {
  breadcrumbs?: { title: string; href?: string }[];
  title: string;
  subtitle?: string;
  statusBadge?: React.ReactNode;
  children?: React.ReactNode; // 操作按钮或筛选器
}

export function PageHeader({
  breadcrumbs,
  title,
  subtitle,
  statusBadge,
  children
}: PageHeaderProps) {
  return (
    <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
      <div>
        {breadcrumbs && breadcrumbs.length > 0 && (
          <Breadcrumb className="mb-2">
            <BreadcrumbList>
              {breadcrumbs.map((crumb, idx) => {
                const isLast = idx === breadcrumbs.length - 1;
                return (
                  <span key={crumb.title} className="inline-flex items-center gap-1.5">
                    <BreadcrumbItem>
                      {isLast || !crumb.href ? (
                        <BreadcrumbPage>{crumb.title}</BreadcrumbPage>
                      ) : (
                        <BreadcrumbLink asChild>
                          <Link href={crumb.href}>{crumb.title}</Link>
                        </BreadcrumbLink>
                      )}
                    </BreadcrumbItem>
                    {!isLast && <BreadcrumbSeparator />}
                  </span>
                );
              })}
            </BreadcrumbList>
          </Breadcrumb>
        )}

        <div className="flex items-center gap-3">
          <h1 className="text-2xl font-semibold tracking-tight md:text-3xl md:font-bold">
            {title}
          </h1>
          {statusBadge}
        </div>

        {subtitle && (
          <p className="text-muted-foreground text-sm mt-1">
            {subtitle}
          </p>
        )}
      </div>

      {children && (
        <div className="flex flex-wrap items-center gap-2">
          {children}
        </div>
      )}
    </div>
  );
}

5. 具有微渐变的效果统计卡片

4.1 渐变与容器查询模式

为实现高端外观且避免视觉杂乱:

  1. 在亮色模式下,应用父级通配符选择器 *:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:bg-gradient-to-t。
  2. 在深色模式下回退到 dark:*:data-[slot=card]:bg-card,以获得干净、哑光的深色模式卡片。
  3. 在卡片上添加 @container/card,以动态缩放标题字体大小(@[250px]/card:text-3xl)。
  4. 通过绝对值或百分比值显示趋势指示器,并使用 lucide-react 的向上或向下箭头图标。

4.2 统计卡片网格

components/dashboard/stats-cards.tsx 中的响应式网格布局:

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { TrendingUp, TrendingDown, DollarSign, Users, ShoppingCart, Activity } from 'lucide-react';

interface StatCard {
  title: string;
  value: string;
  change: number; // 百分比变化
  icon: React.ReactNode;
}

export function StatsCards({ data, isLoading }: { data?: StatCard[]; isLoading: boolean }) {
  return (
    <div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:bg-gradient-to-t dark:*:data-[slot=card]:bg-card grid gap-4 md:grid-cols-2 lg:grid-cols-4">
      {isLoading ? (
        Array.from({ length: 4 }).map((_, i) => (
          <Card key={i} className="@container/card">
            <CardHeader className="relative">
              <CardTitle>
                <Skeleton className="h-4 w-24" />
              </CardTitle>
            </CardHeader>
            <CardContent>
              <Skeleton className="h-8 w-20 @[250px]/card:h-10" />
            </CardContent>
          </Card>
        ))
      ) : (
        data?.map((stat) => (
          <Card key={stat.title} className="@container/card">
            <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
              <CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
              <div className="text-muted-foreground h-4 w-4">{stat.icon}</div>
            </CardHeader>
            <CardContent>
              <div className="flex items-baseline gap-2">
                <span className="text-2xl font-bold tracking-tight @[250px]/card:text-3xl">
                  {stat.value}
                </span>
                <span
                  className={`flex items-center text-xs font-medium ${
                    stat.change >= 0 ? 'text-emerald-500' : 'text-red-500'
                  }`}
                >
                  {stat.change >= 0 ? <TrendingUp className="mr-0.5 h-3 w-3" /> : <TrendingDown className="mr-0.5 h-3 w-3" />}
                  {Math.abs(stat.change)}%
                </span>
              </div>
            </CardContent>
          </Card>
        ))
      )}
    </div>
  );
}

6. 日期范围筛选器

使用 shadcn/ui Calendar 的弹出窗口实现日期筛选器。

5.1 日期选择器组件 (components/dashboard/date-range-picker.tsx)

'use client';

import { format } from 'date-fns';
import { Calendar as CalendarIcon } from 'lucide-react';
import { DateRange } from 'react-day-picker';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';

interface DateRangePickerProps {
  date: DateRange | undefined;
  setDate: (date: DateRange | undefined) => void;
  className?: string;
}

export function DateRangePicker({ date, setDate, className }: DateRangePickerProps) {
  return (
    <Popover>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          size="sm"
          className={`w-[240px] justify-start text-left font-normal ${
            !date ? 'text-muted-foreground' : ''
          }`}
        >
          <CalendarIcon className="mr-2 h-4 w-4" />
          {date?.from ? (
            date.to ? (
              <>
                {format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
              </>
            ) : (
              format(date.from, 'LLL dd, y')
            )
          ) : (
            <span>Pick a date</span>
          )}
        </Button>
      </PopoverTrigger>
      <PopoverContent className="w-auto p-0" align="start">
        <Calendar
          initialFocus
          mode="range"
          selected={date}
          onSelect={setDate}
          numberOfMonths={2}
        />
      </PopoverContent>
    </Popover>
  );
}

7. TanStack 数据表格

所有表格都遵循 TanStack Table 的标准模式,使用 shadcn/ui <Table> 组件进行渲染。

6.1 数据表格组件 (components/dashboard/data-table.tsx)

'use client';

import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
  SortingState,
  getSortedRowModel,
  ColumnFiltersState,
  getFilteredRowModel,
  PaginationState,
  getPaginationRowModel,
} from '@tanstack/react-table';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useState } from 'react';

interface DataTableProps<TData, TValue> {
  columns: ColumnDef<TData, TValue>[];
  data: TData[];
  searchKey?: string;
}

export function DataTable<TData, TValue>({
  columns,
  data,
  searchKey,
}: DataTableProps<TData, TValue>) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
  const [pagination, setPagination] = useState<PaginationState>({
    pageIndex: 0,
    pageSize: 10,
  });

  const table = useReactTable({
    data,
    columns,
    state: {
      sorting,
      columnFilters,
      pagination,
    },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    onPaginationChange: setPagination,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <div className="w-full">
      {searchKey && (
        <div className="flex items-center py-4">
          <Input
            placeholder="Filter..."
            value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ''}
            onChange={(event) =>
              table.getColumn(searchKey)?.setFilterValue(event.target.value)
            }
            className="max-w-sm"
          />
        </div>
      )}

      <div className="rounded-md border">
        <Table>
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => (
                  <TableHead key={header.id}>
                    {header.isPlaceholder
                      ? null
                      : flexRender(
                          header.column.columnDef.header,
                          header.getContext()
                        )}
                  </TableHead>
                ))}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow
                  key={row.id}
                  data-state={row.getIsSelected() && 'selected'}
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext()
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>

      <div className="flex items-center justify-end space-x-2 py-4">
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.previousPage()}
          disabled={!table.getCanPreviousPage()}
        >
          Previous
        </Button>
        <span className="text-sm font-medium">
          Page {table.getState().pagination.pageIndex + 1} of{' '}
          {table.getPageCount()}
        </span>
        <Button
          variant="outline"
          size="sm"
          onClick={() => table.nextPage()}
          disabled={!table.getCanNextPage()}
        >
          Next
        </Button>
      </div>
    </div>
  );
}

相关技能