仪表盘 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 渐变与容器查询模式
为实现高端外观且避免视觉杂乱:
- 在亮色模式下,应用父级通配符选择器
*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card *:data-[slot=card]:bg-gradient-to-t。 - 在深色模式下回退到
dark:*:data-[slot=card]:bg-card,以获得干净、哑光的深色模式卡片。 - 在卡片上添加
@container/card,以动态缩放标题字体大小(@[250px]/card:text-3xl)。 - 通过绝对值或百分比值显示趋势指示器,并使用
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>
);
}