Next.js: 综合项目:仪表盘与 PPR

最后更新:2026-08-26

仪表盘是 SaaS 平台的"驾驶舱"——静态骨架让你瞬间看到布局,动态数据流实时填充核心指标。

1. 你将学到


2. 一个前端架构师的真实故事

(1) 痛点:Dashboard 加载 8 秒,用户全跑了

Alice 的 TaskFlow 在 Beta 测试中收到 50 条用户反馈——92% 抱怨 Dashboard 加载速度。问题出在串行数据获取:先查项目数 → 再查任务数 → 再查成员数 → 最后查活动流,4 个 API 请求瀑布式阻塞,总耗时 8.2 秒。Charlie(市场营销)说:"我等了 10 秒,直接关掉了页面。"

(2) PPR 静态壳 + 并行数据获取的解法

用 PPR 静态壳(Static Shell)让页面骨架瞬间渲染,再用 Promise.all 并行加载 4 个数据源。

TSX
// PPR 静态壳 + 并行数据获取
export default function DashboardPage() {
  return (
    <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
      <Suspense fallback={<StatCardSkeleton />}>
        <ProjectCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <TaskCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <MemberCountWidget />
      </Suspense>
      <Suspense fallback={<StatCardSkeleton />}>
        <ActivityFeedWidget />
      </Suspense>
    </div>
  )
}

(3) 收益

维度 串行加载 PPR + 并行加载
首屏渲染时间 8.2 秒 0.3 秒(静态壳)
数据加载完成 8.2 秒 2.1 秒(并行)
用户体验 白屏 → 跳转 骨架 → 渐进填充
LCP 7.8 秒 1.2 秒
用户留存 8% 跳出率 2% 跳出率

3. 全局导航布局

(1) 布局架构图

100%
graph TB
    subgraph "Root Layout"
        A[RootLayout<br/>html/body/fonts]
    end

    subgraph "Dashboard Layout"
        B[Sidebar<br/>Organization<br/>Navigation<br/>User Menu]
        C[TopNav<br/>Search Bar<br/>Notifications<br/>Breadcrumb]
        D[Main Content<br/>{children}]
    end

    A --> B
    B --> C
    C --> D

    style A fill:#cce5ff
    style B fill:#d4edda
    style C fill:#ffeeba
    style D fill:#f8d7da

(2) Dashboard 布局组件

TSX
// src/app/(dashboard)/layout.tsx
import { Sidebar } from "@/components/layout/sidebar"
import { TopNav } from "@/components/layout/top-nav"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()

  if (!session?.user) {
    redirect("/auth/signin")
  }

  return (
    <div className="flex h-screen overflow-hidden">
      <Sidebar user={session.user} />
      <div className="flex flex-1 flex-col overflow-hidden">
        <TopNav user={session.user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  )
}

▶ 示例:侧边栏组件

TSX
// src/components/layout/sidebar.tsx
import Link from "next/link"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Separator } from "@/components/ui/separator"
import { prisma } from "@/lib/db"

interface SidebarProps {
  user: {
    id: string
    name?: string | null
    email?: string | null
    image?: string | null
  }
}

export async function Sidebar({ user }: SidebarProps) {
  const org = await prisma.user.findUnique({
    where: { id: user.id },
    include: {
      organization: {
        include: {
          _count: { select: { projects: true, users: true } },
        },
      },
    },
  })

  const organization = org?.organization
  const initials = user.name
    ?.split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()

  return (
    <aside className="flex w-64 flex-col border-r bg-card">
      <div className="flex h-14 items-center gap-2 border-b px-6">
        <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">
          TF
        </div>
        <span className="text-lg font-semibold">TaskFlow</span>
      </div>

      <div className="flex-1 space-y-1 p-4">
        <div className="mb-4 rounded-lg bg-muted p-3">
          <p className="text-sm font-medium">{organization?.name}</p>
          <p className="text-xs text-muted-foreground">
            {organization?._count.projects} projects · {organization?._count.users} members
          </p>
        </div>

        <NavItem href="/dashboard" icon="LayoutDashboard" label="Dashboard" />
        <NavItem href="/dashboard/projects" icon="FolderKanban" label="Projects" />
        <NavItem href="/dashboard/tasks" icon="CheckSquare" label="Tasks" />
        <NavItem href="/dashboard/team" icon="Users" label="Team" />
        <NavItem href="/dashboard/settings" icon="Settings" label="Settings" />

        <Separator className="my-4" />

        <p className="mb-2 px-3 text-xs font-medium text-muted-foreground">
          INVITE CODE
        </p>
        <div className="rounded-md border bg-background p-2 text-center font-mono text-sm">
          {organization?.inviteCode}
        </div>
      </div>

      <div className="border-t p-4">
        <div className="flex items-center gap-3">
          <Avatar className="h-8 w-8">
            <AvatarImage src={user.image ?? undefined} />
            <AvatarFallback>{initials || "U"}</AvatarFallback>
          </Avatar>
          <div className="flex-1 truncate">
            <p className="text-sm font-medium">{user.name}</p>
            <p className="text-xs text-muted-foreground truncate">{user.email}</p>
          </div>
        </div>
      </div>
    </aside>
  )
}

function NavItem({
  href,
  icon,
  label,
}: {
  href: string
  icon: string
  label: string
}) {
  const iconMap: Record<string, string> = {
    LayoutDashboard: "\u2302",
    FolderKanban: "\u25A1",
    CheckSquare: "\u2713",
    Users: "\u263A",
    Settings: "\u2699",
  }

  return (
    <Link
      href={href}
      className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
    >
      <span className="text-lg">{iconMap[icon] || "\u2022"}</span>
      {label}
    </Link>
  )
}

▶ 示例:顶部导航栏

TSX
// src/components/layout/top-nav.tsx
import { Breadcrumb } from "@/components/layout/breadcrumb"

export function TopNav() {
  return (
    <header className="flex h-14 items-center gap-4 border-b bg-background px-6">
      <Breadcrumb />
      <div className="ml-auto flex items-center gap-4">
        <button
          type="button"
          className="relative rounded-full p-1 text-muted-foreground hover:text-foreground"
        >
          <span className="text-lg">\uD83D\uDD14</span>
          <span className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
            3
          </span>
        </button>
        <button
          type="button"
          className="rounded-full p-1 text-muted-foreground hover:text-foreground"
        >
          <span className="text-lg">\u2753</span>
        </button>
      </div>
    </header>
  )
}

▶ 示例:面包屑导航

TSX
// src/components/layout/breadcrumb.tsx
"use client"

import { usePathname } from "next/navigation"
import Link from "next/link"

const routeLabels: Record<string, string> = {
  dashboard: "Dashboard",
  projects: "Projects",
  tasks: "Tasks",
  team: "Team",
  settings: "Settings",
}

export function Breadcrumb() {
  const pathname = usePathname()
  const segments = pathname.split("/").filter(Boolean)

  return (
    <nav aria-label="Breadcrumb" className="flex items-center gap-2 text-sm">
      <Link href="/dashboard" className="text-muted-foreground hover:text-foreground">
        Home
      </Link>
      {segments.slice(1).map((segment, index) => {
        const href = "/" + segments.slice(0, index + 2).join("/")
        const label = routeLabels[segment] || segment.replace(/-/g, " ")
        const isLast = index === segments.length - 2

        return (
          <span key={segment} className="flex items-center gap-2">
            <span className="text-muted-foreground">/</span>
            {isLast ? (
              <span className="font-medium text-foreground">{label}</span>
            ) : (
              <Link href={href} className="text-muted-foreground hover:text-foreground">
                {label}
              </Link>
            )}
          </span>
        )
      })}
    </nav>
  )
}

4. Dashboard PPR 静态壳

(1) PPR 工作原理

100%
sequenceDiagram
    participant B as Browser
    participant N as Next.js Server
    participant DB as Database

    B->>N: GET /dashboard
    N->>B: Static Shell (HTML + CSS)<br/>Skeleton cards visible
    B->>N: Streaming starts
    N->>DB: Promise.all<br/>(4 parallel queries)
    DB-->>N: Results
    N->>B: Dynamic content fills skeletons
    Note over B: Progressive enhancement

(2) 开启 PPR

TYPESCRIPT
// next.config.ts
import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  experimental: {
    ppr: true,
  },
}

export default nextConfig

▶ 示例:Dashboard 页面 PPR 实现

TSX
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"

export default function DashboardPage() {
  return (
    <div className="space-y-6">
      <Suspense fallback={<div className="h-24 rounded-lg bg-muted animate-pulse" />}>
        <WelcomeBanner />
      </Suspense>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<StatCardSkeleton />}>
          <ProjectCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <MemberCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCompletionWidget />
        </Suspense>
      </div>

      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
            <ActivityFeedWidget />
          </Suspense>
        </div>
        <div>
          <Suspense fallback={<div className="h-80 rounded-lg bg-muted animate-pulse" />}>
            <RecentProjectsWidget />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

(3) 数据获取 Widget 实现

TSX
// src/components/dashboard/project-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

export async function ProjectCountWidget() {
  const session = await auth()

  const count = await prisma.project.count({
    where: {
      organization: {
        users: { some: { id: session?.user?.id } },
      },
      status: "ACTIVE",
    },
  })

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">Active Projects</CardTitle>
        <span className="text-2xl">\uD83D\uDCC1</span>
      </CardHeader>
      <CardContent>
        <div className="text-3xl font-bold">{count}</div>
        <p className="text-xs text-muted-foreground">Across your organization</p>
      </CardContent>
    </Card>
  )
}
TSX
// src/components/dashboard/task-count-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"

export async function TaskCountWidget() {
  const session = await auth()

  const counts = await prisma.task.groupBy({
    by: ["status"],
    where: {
      project: {
        organization: {
          users: { some: { id: session?.user?.id } },
        },
      },
    },
    _count: true,
  })

  const total = counts.reduce((acc, c) => acc + c._count, 0)
  const todo = counts.find((c) => c.status === "TODO")?._count || 0
  const inProgress = counts.find((c) => c.status === "IN_PROGRESS")?._count || 0
  const done = counts.find((c) => c.status === "DONE")?._count || 0

  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <CardTitle className="text-sm font-medium">Total Tasks</CardTitle>
        <span className="text-2xl">\u2705</span>
      </CardHeader>
      <CardContent>
        <div className="text-3xl font-bold">{total}</div>
        <div className="mt-2 flex gap-2 text-xs text-muted-foreground">
          <span className="text-yellow-500">{todo} todo</span>
          <span className="text-blue-500">{inProgress} in progress</span>
          <span className="text-green-500">{done} done</span>
        </div>
      </CardContent>
    </Card>
  )
}

5. 并行数据获取

(1) 串行 vs 并行对比

100%
graph LR
    subgraph "Serial (8.2s)"
        A1[Fetch A] --> A2[Fetch B] --> A3[Fetch C] --> A4[Fetch D]
    end

    subgraph "Parallel (2.1s)"
        B1[Fetch A]
        B2[Fetch B]
        B3[Fetch C]
        B4[Fetch D]
        B1 --> C[Promise.all]
        B2 --> C
        B3 --> C
        B4 --> C
    end

    style A4 fill:#f8d7da
    style C fill:#d4edda

▶ 示例:Promise.all 并行获取活动流

TSX
// src/components/dashboard/activity-feed-widget.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

export async function ActivityFeedWidget() {
  const session = await auth()

  const [recentTasks, recentComments] = await Promise.all([
    prisma.task.findMany({
      where: {
        project: {
          organization: {
            users: { some: { id: session?.user?.id } },
          },
        },
      },
      orderBy: { createdAt: "desc" },
      take: 10,
      include: {
        creator: { select: { name: true, image: true } },
        project: { select: { name: true } },
      },
    }),
    prisma.comment.findMany({
      where: {
        task: {
          project: {
            organization: {
              users: { some: { id: session?.user?.id } },
            },
          },
        },
      },
      orderBy: { createdAt: "desc" },
      take: 10,
      include: {
        author: { select: { name: true, image: true } },
        task: { select: { title: true } },
      },
    }),
  ])

  const activities = [
    ...recentTasks.map((t) => ({
      id: t.id,
      type: "task_created" as const,
      user: t.creator,
      description: `created task "${t.title}"`,
      project: t.project.name,
      time: t.createdAt,
    })),
    ...recentComments.map((c) => ({
      id: c.id,
      type: "comment_added" as const,
      user: c.author,
      description: `commented on "${c.task.title}"`,
      project: "",
      time: c.createdAt,
    })),
  ]
    .sort((a, b) => b.time.getTime() - a.time.getTime())
    .slice(0, 10)

  return (
    <Card>
      <CardHeader>
        <CardTitle>Activity Feed</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-4">
          {activities.map((activity) => (
            <div key={activity.id} className="flex items-start gap-3">
              <Avatar className="h-8 w-8">
                <AvatarImage src={activity.user.image ?? undefined} />
                <AvatarFallback>
                  {activity.user.name?.[0] || "U"}
                </AvatarFallback>
              </Avatar>
              <div className="flex-1 space-y-1">
                <p className="text-sm">
                  <span className="font-medium">{activity.user.name}</span>
                  {" "}{activity.description}
                </p>
                <p className="text-xs text-muted-foreground">
                  {activity.project && `${activity.project} · `}
                  {activity.time.toLocaleDateString("en-US", {
                    month: "short",
                    day: "numeric",
                    hour: "2-digit",
                    minute: "2-digit",
                  })}
                </p>
              </div>
            </div>
          ))}
          {activities.length === 0 && (
            <p className="text-sm text-muted-foreground">
              No recent activity
            </p>
          )}
        </div>
      </CardContent>
    </Card>
  )
}

(2) 数据获取策略对比

策略 总耗时 实现复杂度 适用场景
串行 await Sum(N) 简单 数据有依赖关系
Promise.all Max(N) 中等 独立数据源
Promise.allSettled Max(N) 较高 部分失败可容忍
Streaming + PPR TTFB ~0 首屏优先

6. 错误边界与加载骨架

(1) 错误边界层级

100%
graph TB
    A[RootLayout] --> B[error.tsx<br/>Global Error]
    A --> C[DashboardLayout]
    C --> D[error.tsx<br/>Dashboard Error]
    D --> E[Dashboard Page]
    E --> F[Widget 1<br/>Suspense fallback]
    E --> G[Widget 2<br/>Suspense fallback]
    E --> H[Widget 3<br/>Error Boundary]

    style B fill:#f8d7da
    style D fill:#f8d7da
    style F fill:#d4edda
    style G fill:#d4edda

▶ 示例:全局错误边界

TSX
// src/app/(dashboard)/error.tsx
"use client"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div className="flex min-h-[400px] items-center justify-center p-6">
      <Card className="mx-auto max-w-md">
        <CardHeader>
          <CardTitle className="text-destructive">Something went wrong</CardTitle>
          <CardDescription>
            An unexpected error occurred while loading the dashboard.
          </CardDescription>
        </CardHeader>
        <CardContent>
          <div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
            {error.message || "Unknown error"}
          </div>
          {error.digest && (
            <p className="mt-2 text-xs text-muted-foreground">
              Error ID: {error.digest}
            </p>
          )}
        </CardContent>
        <CardFooter>
          <Button onClick={() => reset()} variant="outline" className="w-full">
            Try again
          </Button>
        </CardFooter>
      </Card>
    </div>
  )
}

▶ 示例:骨架屏组件

TSX
// src/components/dashboard/stat-card-skeleton.tsx
import { Card, CardContent, CardHeader } from "@/components/ui/card"

export function StatCardSkeleton() {
  return (
    <Card>
      <CardHeader className="flex flex-row items-center justify-between pb-2">
        <div className="h-4 w-24 animate-pulse rounded bg-muted" />
        <div className="h-6 w-6 animate-pulse rounded bg-muted" />
      </CardHeader>
      <CardContent>
        <div className="mb-2 h-9 w-16 animate-pulse rounded bg-muted" />
        <div className="h-3 w-32 animate-pulse rounded bg-muted" />
      </CardContent>
    </Card>
  )
}
TSX
// src/app/(dashboard)/dashboard/loading.tsx
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"

export default function DashboardLoading() {
  return (
    <div className="space-y-6">
      <div className="h-24 animate-pulse rounded-lg bg-muted" />

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
        <StatCardSkeleton />
        <StatCardSkeleton />
        <StatCardSkeleton />
        <StatCardSkeleton />
      </div>

      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <div className="h-80 animate-pulse rounded-lg bg-muted" />
        </div>
        <div>
          <div className="h-80 animate-pulse rounded-lg bg-muted" />
        </div>
      </div>
    </div>
  )
}

7. 静态项目详情页

(1) 静态页面生成策略

策略 生成时机 数据新鲜度 构建时间 适用场景
SSG (generateStaticParams) 构建时 构建时的快照 慢(N 页) 内容很少变化
ISR (revalidate) 构建时 + 按需 滞后 TTL 慢(N 页) 定期更新
SSR (dynamic) 请求时 实时 快(无需 N) 实时数据
PPR 构建时 + 流式 实时动态部分 快(静态壳) 混合场景

▶ 示例:项目详情页静态生成

TSX
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { notFound } from "next/navigation"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { ProjectTasks } from "@/components/projects/project-tasks"
import { ProjectMembers } from "@/components/projects/project-members"

export async function generateStaticParams() {
  const projects = await prisma.project.findMany({
    select: { id: true },
    where: { status: "ACTIVE" },
  })

  return projects.map((project) => ({
    id: project.id,
  }))
}

export default async function ProjectDetailPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const session = await auth()

  const project = await prisma.project.findUnique({
    where: { id },
    include: {
      organization: true,
      creator: { select: { name: true, image: true } },
      _count: { select: { tasks: true } },
    },
  })

  if (!project) {
    notFound()
  }

  const statusColors: Record<string, string> = {
    ACTIVE: "bg-green-500",
    ARCHIVED: "bg-gray-500",
    COMPLETED: "bg-blue-500",
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <div>
          <div className="flex items-center gap-3">
            <div
              className="h-4 w-4 rounded-full"
              style={{ backgroundColor: project.color }}
            />
            <h1 className="text-2xl font-bold">{project.name}</h1>
            <Badge
              variant="secondary"
              className={statusColors[project.status]}
            >
              {project.status}
            </Badge>
          </div>
          {project.description && (
            <p className="mt-1 text-muted-foreground">
              {project.description}
            </p>
          )}
          <div className="mt-2 flex items-center gap-4 text-sm text-muted-foreground">
            <span>Created by {project.creator.name}</span>
            <span>{project._count.tasks} tasks</span>
            <span>{project.organization.name}</span>
          </div>
        </div>
      </div>

      <div className="grid gap-6 lg:grid-cols-3">
        <div className="lg:col-span-2">
          <ProjectTasks projectId={project.id} />
        </div>
        <div>
          <ProjectMembers projectId={project.id} />
        </div>
      </div>
    </div>
  )
}
TSX
// src/components/projects/project-tasks.tsx
import { prisma } from "@/lib/db"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"

interface ProjectTasksProps {
  projectId: string
}

const statusBadge: Record<string, string> = {
  TODO: "bg-yellow-100 text-yellow-800",
  IN_PROGRESS: "bg-blue-100 text-blue-800",
  DONE: "bg-green-100 text-green-800",
}

export async function ProjectTasks({ projectId }: ProjectTasksProps) {
  const tasks = await prisma.task.findMany({
    where: { projectId },
    orderBy: { order: "asc" },
    include: {
      assignee: { select: { name: true, image: true } },
    },
  })

  return (
    <Card>
      <CardHeader>
        <CardTitle>Tasks ({tasks.length})</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="space-y-2">
          {tasks.map((task) => (
            <div
              key={task.id}
              className="flex items-center justify-between rounded-lg border p-3"
            >
              <div className="flex items-center gap-3">
                <input
                  type="checkbox"
                  defaultChecked={task.status === "DONE"}
                  className="h-4 w-4 rounded border-gray-300"
                />
                <span
                  className={
                    task.status === "DONE" ? "text-muted-foreground line-through" : ""
                  }
                >
                  {task.title}
                </span>
              </div>
              <div className="flex items-center gap-2">
                <Badge className={statusBadge[task.status]} variant="secondary">
                  {task.status.replace("_", " ")}
                </Badge>
                {task.assignee && (
                  <span className="text-xs text-muted-foreground">
                    {task.assignee.name}
                  </span>
                )}
              </div>
            </div>
          ))}
          {tasks.length === 0 && (
            <p className="py-8 text-center text-sm text-muted-foreground">
              No tasks yet. Create your first task to get started.
            </p>
          )}
        </div>
      </CardContent>
    </Card>
  )
}

8. Router Groups 与布局组织

(1) Route Groups 结构

TEXT 📖 仅展示
src/app/
├── (auth)/                 # Auth 路由组(无侧边栏)
│   └── auth/
│       ├── signin/
│       └── signup/
├── (dashboard)/            # Dashboard 路由组(有侧边栏)
│   ├── layout.tsx          # Dashboard 布局(Sidebar + TopNav)
│   ├── dashboard/
│   │   └── page.tsx
│   └── projects/
│       └── [id]/
│           └── page.tsx
├── layout.tsx              # 根布局(html/body)
└── page.tsx                # 首页(公开)

▶ 示例:根布局与字体优化

TSX
// src/app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
import "./globals.css"

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
  variable: "--font-inter",
})

export const metadata: Metadata = {
  title: {
    default: "TaskFlow - Project Management",
    template: "%s | TaskFlow",
  },
  description: "Enterprise project management platform for modern teams",
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en" className={inter.variable}>
      <body className="min-h-screen bg-background font-sans antialiased">
        {children}
      </body>
    </html>
  )
}

9. 完整示例:Dashboard 完整页面

TSX
// src/app/(dashboard)/dashboard/page.tsx
import { Suspense } from "react"
import { auth } from "@/lib/auth"
import { redirect } from "next/navigation"
import { StatCardSkeleton } from "@/components/dashboard/stat-card-skeleton"
import { ProjectCountWidget } from "@/components/dashboard/project-count-widget"
import { TaskCountWidget } from "@/components/dashboard/task-count-widget"
import { MemberCountWidget } from "@/components/dashboard/member-count-widget"
import { TaskCompletionWidget } from "@/components/dashboard/task-completion-widget"
import { ActivityFeedWidget } from "@/components/dashboard/activity-feed-widget"
import { RecentProjectsWidget } from "@/components/dashboard/recent-projects-widget"
import { WelcomeBanner } from "@/components/dashboard/welcome-banner"

export default async function DashboardPage() {
  const session = await auth()

  if (!session?.user) {
    redirect("/auth/signin")
  }

  return (
    <div className="space-y-6">
      <Suspense fallback={<div className="h-24 animate-pulse rounded-lg bg-muted" />}>
        <WelcomeBanner user={session.user} />
      </Suspense>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
        <Suspense fallback={<StatCardSkeleton />}>
          <ProjectCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <MemberCountWidget />
        </Suspense>
        <Suspense fallback={<StatCardSkeleton />}>
          <TaskCompletionWidget />
        </Suspense>
      </div>

      <div className="grid gap-6 lg:grid-cols-7">
        <div className="lg:col-span-4">
          <Suspense
            fallback={
              <div className="h-96 animate-pulse rounded-lg bg-muted" />
            }
          >
            <ActivityFeedWidget />
          </Suspense>
        </div>
        <div className="lg:col-span-3">
          <Suspense
            fallback={
              <div className="h-96 animate-pulse rounded-lg bg-muted" />
            }
          >
            <RecentProjectsWidget />
          </Suspense>
        </div>
      </div>
    </div>
  )
}

预期输出(浏览器访问 /dashboard):

TEXT 📖 仅展示
→ 瞬间看到:侧边栏 + 顶部导航 + 面包屑(静态壳)
→ 0.3s: 欢迎横幅出现
→ 0.5s: 4 个统计卡片骨架 → 填充数据(12 / 50 / 5 / 68%)
→ 1.2s: 活动流 + 最近项目列表
→ 页面完全交互

❓ 常见问题

Q PPR 和 loading.tsx 的区别是什么?
A loading.tsx 是整个页面的加载态(整个页面替换),而 PPR 的 <Suspense> 是组件级的流式加载。PPR 可以在构建时预先生成静态 HTML,动态部分通过流式填充;loading.tsx 则是完全动态渲染。
Q generateStaticParams 适合所有项目页面吗?
A 不适合。如果项目数量大(10,000+),构建时间会非常长。TaskFlow 中只对活跃项目(ACTIVE)做静态生成,结合 ISR 按需更新。对于超大规模项目,推荐使用 SSR + 缓存策略。
Q Promise.all 中一个请求失败了怎么办?
A Promise.all 是"一个失败全部失败"策略。对于非关键数据(如活动流),用 Promise.allSettled 更安全——即使活动流失败,统计数据仍然能显示。错误边界 + fallback 提供降级体验。
Q Breadcrumb 用 usePathname 是不是客户端组件?
A 是的,usePathname 是客户端 Hook。Breadcrumb 组件很小(无数据获取),客户端渲染无性能问题。如果在意,可以用服务端的 headers().get("next-url") 实现,但代码复杂度更高。
Q 为什么 Dashboard Layout 中要再检查一次 session?
A 虽然 middleware.ts 已经保护了 /dashboard 路由,但 Layout 中再次检查是"深度防御"——防止直接访问嵌套路由(如 /dashboard/projects/xxx)时绕过检查。Next.js 建议在布局和数据获取层都做认证检查。
Q 骨架屏动画会影响 CLS(累积布局偏移)吗?
A 不会。骨架屏预先占据与真实内容相同的尺寸(高度/宽度),不会导致布局偏移。这是 PPR 和 loading.tsx 的核心优势——让用户立即看到布局结构,消除白屏跳转导致的 CLS。

📖 小节

📝 作业

  1. 基础题(⭐):在 TaskFlow 项目中实现 Dashboard 页面,确保 4 个统计卡片用 PPR 静态壳渲染。运行 npm run build 验证 PPR 是否生效(查看构建日志是否有 ○ (Static)λ (Dynamic) 标记)。

  2. 进阶题(⭐⭐):在活动流 Widget 中换用 Promise.allSettled 替代 Promise.all,添加错误状态显示(当活动流加载失败时显示"Unable to load activity"而非让整个 Widget 崩溃)。编写降级 UI 代码。

  3. 挑战题(⭐⭐⭐):为 TaskFlow 实现一个全功能的项目详情页——支持 SSR(确保实时数据) + generateStaticParams 为 TOP 10 活跃项目做 ISR(revalidate: 60),其余项目走 SSR。添加 loading.tsx 骨架屏 + error.tsx 边界 + 面包屑动态更新。使用 Lighthouse 对比三种策略的性能差异。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏