Next.js: 综合项目:CRUD 与 Server Actions

最后更新:2026-08-26

任务管理是 SaaS 平台的核心——创建、编辑、删除、拖拽排序、文件上传、搜索筛选,一套完整的 CRUD 系统让用户高效管理工作流。

1. 你将学到


2. 一个项目经理的真实故事

(1) 痛点:50 人团队每天浪费 3 小时在任务管理上

Bob 是 Acme Corp 的项目经理,管理着 5 个项目 200+ 任务。团队每天花大量时间在:创建任务(表单 10 个字段,反复填)、拖拽调整状态(白板贴便利贴)、找人要文件(微信传来传去)、搜索任务(翻 Excel 表格)。Alice 统计过——50 人团队每人每天浪费 36 分钟在低效的任务管理上,相当于每月 600 小时生产力损失。

(2) Server Actions + 乐观更新 + 拖拽的解法

用 Server Actions 实现零刷新 CRUD + useOptimistic 即时反馈 + 拖拽式状态切换。

TSX
// 一行 Server Action 搞定任务创建 + 缓存刷新
<form action={createTask}>
  <input name="title" required />
  <input name="projectId" type="hidden" value={project.id} />
  <button type="submit">Create Task</button>
</form>

(3) 收益

场景 优化前 优化后 效率提升
创建任务 3 分钟(全表单) 15 秒(快速创建) 12x
拖拽排序 手写白板 点击状态列 即时
文件共享 微信传文件 uploadthing 上传 5x
搜索任务 翻 Excel DataTable 实时搜索 20x
每月生产力 600 小时浪费 ~50 小时浪费 92% 减少

3. Server Actions 任务 CRUD

(1) 操作架构

100%
graph TB
    subgraph "Server Actions"
        A[createTask] --> B[Zod validate]
        B --> C[Prisma create]
        C --> D[revalidateTag]
        D --> E[redirect]
    end

    subgraph "Client"
        F[TaskForm] -->|action| A
        G[useOptimistic] -->|instant UI| F
        H[useActionState] -->|confirm| J[deleteTask]
    end

    subgraph "Cache"
        D --> K[revalidateTag<br/>"tasks"]
        K --> L[Refresh UI]
    end

    style A fill:#cce5ff
    style G fill:#d4edda
    style K fill:#ffeeba

(2) 任务操作对比

操作 Server Action 校验 缓存策略 UI 反馈
createTask "use server" Zod revalidateTag("tasks") useOptimistic
updateTask "use server" Zod partial revalidateTag("tasks") 乐观更新
deleteTask "use server" 确认检查 revalidateTag("tasks") useActionState
reorderTask "use server" 数字校验 revalidateTag("tasks") 立即重排

▶ 示例:Server Actions 定义

TYPESCRIPT
// src/lib/actions/task-actions.ts
"use server"

import { revalidateTag } from "next/cache"
import { redirect } from "next/navigation"
import { z } from "zod"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TaskStatus, TaskPriority } from "@prisma/client"

const createTaskSchema = z.object({
  title: z.string().min(1, "Title is required").max(200, "Title too long"),
  description: z.string().max(2000).optional(),
  projectId: z.string().min(1),
  assigneeId: z.string().optional(),
  priority: z.nativeEnum(TaskPriority).optional(),
  dueDate: z.string().optional(),
})

const updateTaskSchema = z.object({
  id: z.string().min(1),
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(2000).optional(),
  status: z.nativeEnum(TaskStatus).optional(),
  priority: z.nativeEnum(TaskPriority).optional(),
  assigneeId: z.string().nullable().optional(),
  dueDate: z.string().nullable().optional(),
  order: z.number().int().optional(),
})

export async function createTask(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("Unauthorized")
  }

  const rawData = {
    title: formData.get("title") as string,
    description: formData.get("description") as string | undefined,
    projectId: formData.get("projectId") as string,
    assigneeId: formData.get("assigneeId") as string | undefined,
    priority: (formData.get("priority") as TaskPriority) || "MEDIUM",
    dueDate: formData.get("dueDate") as string | undefined,
  }

  const validated = createTaskSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      error: validated.error.flatten().fieldErrors,
      message: "Validation failed",
    }
  }

  const data = validated.data

  const maxOrder = await prisma.task.findFirst({
    where: { projectId: data.projectId, status: "TODO" },
    orderBy: { order: "desc" },
    select: { order: true },
  })

  await prisma.task.create({
    data: {
      title: data.title,
      description: data.description || null,
      status: "TODO",
      priority: data.priority || "MEDIUM",
      projectId: data.projectId,
      creatorId: session.user.id,
      assigneeId: data.assigneeId || null,
      dueDate: data.dueDate ? new Date(data.dueDate) : null,
      order: (maxOrder?.order ?? -1) + 1,
    },
  })

  revalidateTag("tasks")
  revalidateTag(`project-${data.projectId}`)
}

export async function updateTask(formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("Unauthorized")
  }

  const rawData = {
    id: formData.get("id") as string,
    title: formData.get("title") as string | undefined,
    description: formData.get("description") as string | undefined,
    status: formData.get("status") as TaskStatus | undefined,
    priority: formData.get("priority") as TaskPriority | undefined,
    assigneeId: formData.get("assigneeId") as string | null | undefined,
    dueDate: formData.get("dueDate") as string | null | undefined,
  }

  const validated = updateTaskSchema.safeParse(rawData)

  if (!validated.success) {
    return {
      error: validated.error.flatten().fieldErrors,
      message: "Validation failed",
    }
  }

  const data = validated.data
  const updateData: Record<string, unknown> = {}

  if (data.title !== undefined) updateData.title = data.title
  if (data.description !== undefined) updateData.description = data.description
  if (data.status !== undefined) updateData.status = data.status
  if (data.priority !== undefined) updateData.priority = data.priority
  if (data.assigneeId !== undefined) updateData.assigneeId = data.assigneeId
  if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null
  if (data.order !== undefined) updateData.order = data.order

  await prisma.task.update({
    where: { id: data.id },
    data: updateData,
  })

  revalidateTag("tasks")
}

export async function deleteTask(prevState: { message: string }, formData: FormData) {
  const session = await auth()
  if (!session?.user?.id) {
    return { message: "Unauthorized" }
  }

  const taskId = formData.get("id") as string

  if (!taskId) {
    return { message: "Task ID is required" }
  }

  const task = await prisma.task.findUnique({
    where: { id: taskId },
    select: { creatorId: true, projectId: true },
  })

  if (!task) {
    return { message: "Task not found" }
  }

  if (task.creatorId !== session.user.id) {
    return { message: "Only the creator can delete this task" }
  }

  await prisma.task.delete({ where: { id: taskId } })

  revalidateTag("tasks")
  revalidateTag(`project-${task.projectId}`)

  return { message: "Task deleted successfully" }
}

export async function reorderTask(taskId: string, newStatus: TaskStatus, newOrder: number) {
  const session = await auth()
  if (!session?.user?.id) {
    throw new Error("Unauthorized")
  }

  await prisma.task.update({
    where: { id: taskId },
    data: { status: newStatus, order: newOrder },
  })

  revalidateTag("tasks")
}

4. 乐观更新与确认弹窗

(1) 三种 UI 模式

100%
graph LR
    subgraph "Optimistic Update"
        A1[User clicks] --> B1[Instant UI update]
        B1 --> C1[Server Action]
        C1 --> D1{Rollback?}
        D1 -->|Success| E1[Keep]
        D1 -->|Error| F1[Revert]
    end

    subgraph "Action State"
        A2[User clicks delete] --> B2[Show confirm dialog]
        B2 --> C2[If confirmed<br/>call deleteTask]
        C2 --> D2[Show result message]
    end

    subgraph "Traditional"
        A3[Submit form] --> B3[Loading spinner]
        B3 --> C3[Server Action]
        C3 --> D3[revalidateTag + refresh]
    end

    style B1 fill:#d4edda
    style B2 fill:#ffeeba
    style B3 fill:#f8d7da

▶ 示例:useOptimistic 乐观更新

TSX
// src/components/tasks/task-list.tsx
"use client"

import { useOptimistic } from "react"
import { TaskCard } from "@/components/tasks/task-card"
import type { TaskWithAssignee } from "@/types"

interface TaskListProps {
  tasks: TaskWithAssignee[]
  status: "TODO" | "IN_PROGRESS" | "DONE"
  onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}

export function TaskList({ tasks, status, onStatusChange }: TaskListProps) {
  const [optimisticTasks, addOptimisticTask] = useOptimistic(
    tasks,
    (state, newTask: TaskWithAssignee) => {
      return [newTask, ...state]
    }
  )

  return (
    <div className="space-y-2">
      <h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
        {status.replace("_", " ")}
        <span className="ml-2 text-xs">({optimisticTasks.length})</span>
      </h3>
      {optimisticTasks.length === 0 ? (
        <div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
          No tasks
        </div>
      ) : (
        <div className="space-y-2">
          {optimisticTasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              onStatusChange={onStatusChange}
            />
          ))}
        </div>
      )}
    </div>
  )
}

▶ 示例:useActionState 确认删除

TSX
// src/components/tasks/delete-task-button.tsx
"use client"

import { useActionState, useState } from "react"
import { deleteTask } from "@/lib/actions/task-actions"
import { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogFooter,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"

interface DeleteTaskButtonProps {
  taskId: string
  taskTitle: string
}

export function DeleteTaskButton({ taskId, taskTitle }: DeleteTaskButtonProps) {
  const [open, setOpen] = useState(false)
  const [state, formAction, isPending] = useActionState(deleteTask, {
    message: "",
  })

  return (
    <Dialog open={open} onOpenChange={setOpen}>
      <DialogTrigger asChild>
        <Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive">
          <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
          </svg>
        </Button>
      </DialogTrigger>
      <DialogContent>
        <form action={formAction}>
          <input type="hidden" name="id" value={taskId} />
          <DialogHeader>
            <DialogTitle>Delete Task</DialogTitle>
            <DialogDescription>
              Are you sure you want to delete "{taskTitle}"? This action cannot be undone.
            </DialogDescription>
          </DialogHeader>
          {state.message && state.message !== "Task deleted successfully" && (
            <div className="my-4 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
              {state.message}
            </div>
          )}
          {state.message === "Task deleted successfully" && (
            <div className="my-4 rounded-md bg-green-50 p-3 text-sm text-green-700">
              {state.message}
            </div>
          )}
          <DialogFooter className="mt-4">
            <Button
              type="button"
              variant="outline"
              onClick={() => setOpen(false)}
            >
              Cancel
            </Button>
            <Button
              type="submit"
              variant="destructive"
              disabled={isPending}
            >
              {isPending ? "Deleting..." : "Delete"}
            </Button>
          </DialogFooter>
        </form>
      </DialogContent>
    </Dialog>
  )
}

5. 拖拽排序(三列看板)

(1) 看板架构

100%
graph LR
    subgraph "Task Board"
        A["Todo<br/>(Column)"] -->|Drag| B["In Progress<br/>(Column)"]
        B -->|Drag| C["Done<br/>(Column)"]
        C -->|Drag back| B
        B -->|Drag back| A
    end

    style A fill:#fef3c7
    style B fill:#dbeafe
    style C fill:#d1fae5

▶ 示例:任务看板三列布局

TSX
// src/components/tasks/task-board.tsx
"use client"

import { useCallback } from "react"
import { TaskList } from "@/components/tasks/task-list"
import { reorderTask } from "@/lib/actions/task-actions"
import type { TaskWithAssignee } from "@/types"

interface TaskBoardProps {
  tasks: TaskWithAssignee[]
}

export function TaskBoard({ tasks }: TaskBoardProps) {
  const todoTasks = tasks.filter((t) => t.status === "TODO")
  const inProgressTasks = tasks.filter((t) => t.status === "IN_PROGRESS")
  const doneTasks = tasks.filter((t) => t.status === "DONE")

  const handleStatusChange = useCallback(
    async (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => {
      const targetList = tasks.filter((t) => t.status === newStatus)
      const maxOrder = targetList.length > 0
        ? Math.max(...targetList.map((t) => t.order))
        : 0

      try {
        await reorderTask(taskId, newStatus, maxOrder + 1)
      } catch (error) {
        console.error("Failed to reorder task:", error)
      }
    },
    [tasks]
  )

  return (
    <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={todoTasks}
          status="TODO"
          onStatusChange={handleStatusChange}
        />
      </div>
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={inProgressTasks}
          status="IN_PROGRESS"
          onStatusChange={handleStatusChange}
        />
      </div>
      <div className="rounded-lg bg-muted/50 p-4">
        <TaskList
          tasks={doneTasks}
          status="DONE"
          onStatusChange={handleStatusChange}
        />
      </div>
    </div>
  )
}

▶ 示例:任务卡片组件

TSX
// src/components/tasks/task-card.tsx
"use client"

import { DeleteTaskButton } from "@/components/tasks/delete-task-button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import type { TaskWithAssignee } from "@/types"

interface TaskCardProps {
  task: TaskWithAssignee
  onStatusChange: (taskId: string, newStatus: "TODO" | "IN_PROGRESS" | "DONE") => void
}

const statusActions: Record<string, ("TODO" | "IN_PROGRESS" | "DONE")[]> = {
  TODO: ["IN_PROGRESS"],
  IN_PROGRESS: ["TODO", "DONE"],
  DONE: ["IN_PROGRESS"],
}

const priorityColors: Record<string, string> = {
  LOW: "bg-gray-100 text-gray-700",
  MEDIUM: "bg-blue-100 text-blue-700",
  HIGH: "bg-orange-100 text-orange-700",
  URGENT: "bg-red-100 text-red-700",
}

export function TaskCard({ task, onStatusChange }: TaskCardProps) {
  const initials = task.assignee?.name
    ?.split(" ")
    .map((n) => n[0])
    .join("")
    .toUpperCase()

  return (
    <div className="group rounded-lg border bg-card p-3 shadow-sm transition-all hover:shadow-md">
      <div className="flex items-start justify-between">
        <h4 className="text-sm font-medium">{task.title}</h4>
        <DeleteTaskButton taskId={task.id} taskTitle={task.title} />
      </div>

      {task.description && (
        <p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
          {task.description}
        </p>
      )}

      <div className="mt-3 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <Badge
            variant="secondary"
            className={priorityColors[task.priority]}
          >
            {task.priority}
          </Badge>
          {task.dueDate && (
            <span className="text-xs text-muted-foreground">
              {new Date(task.dueDate).toLocaleDateString("en-US", {
                month: "short",
                day: "numeric",
              })}
            </span>
          )}
        </div>

        <div className="flex items-center gap-2">
          <div className="flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
            {statusActions[task.status].map((action) => (
              <button
                key={action}
                onClick={() => onStatusChange(task.id, action)}
                className="rounded px-2 py-1 text-[10px] font-medium uppercase transition-colors hover:bg-accent"
              >
                {action === "TODO"
                  ? "\u21A9"
                  : action === "IN_PROGRESS"
                  ? "\u25B6"
                  : "\u2713"}
              </button>
            ))}
          </div>

          {task.assignee && (
            <Avatar className="h-6 w-6">
              <AvatarImage src={task.assignee.image ?? undefined} />
              <AvatarFallback className="text-[10px]">
                {initials}
              </AvatarFallback>
            </Avatar>
          )}
        </div>
      </div>

      {task._count?.comments > 0 && (
        <div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
          <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
          </svg>
          {task._count.comments}
        </div>
      )}
    </div>
  )
}

6. 文件上传(uploadthing)

(1) 上传架构

100%
sequenceDiagram
    participant C as Client
    participant U as UploadThing
    participant S as Server Action
    participant DB as Database

    C->>U: Upload file (multipart)
    U-->>C: Return fileUrl + metadata
    C->>S: Server Action(formData with fileUrl)
    S->>DB: Create Attachment record
    DB-->>S: Attachment created
    S->>S: revalidateTag("tasks")
    S-->>C: Success

▶ 示例:UploadThing 配置

TYPESCRIPT
// src/lib/uploadthing.ts
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { auth } from "@/lib/auth"

const f = createUploadthing()

export const ourFileRouter = {
  taskAttachment: f({
    image: { maxFileSize: "4MB", maxFileCount: 5 },
    pdf: { maxFileSize: "8MB", maxFileCount: 3 },
    "text/*": { maxFileSize: "2MB", maxFileCount: 5 },
  })
    .middleware(async () => {
      const session = await auth()
      if (!session?.user) throw new Error("Unauthorized")
      return { userId: session.user.id }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      return { uploadedBy: metadata.userId, fileUrl: file.url }
    }),
} satisfies FileRouter

export type OurFileRouter = typeof ourFileRouter
TYPESCRIPT
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "@/lib/uploadthing"

export const { GET, POST } = createRouteHandler({
  router: ourFileRouter,
})

▶ 示例:文件上传组件

TSX
// src/components/tasks/task-attachment-upload.tsx
"use client"

import { useCallback, useState } from "react"
import { useDropzone } from "@uploadthing/react"
import { generateClientDropzoneAccept } from "uploadthing/client"
import { Button } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"

interface TaskAttachmentUploadProps {
  taskId: string
  onUploadComplete: (fileUrl: string, fileName: string) => void
}

export function TaskAttachmentUpload({ taskId, onUploadComplete }: TaskAttachmentUploadProps) {
  const [isUploading, setIsUploading] = useState(false)
  const [progress, setProgress] = useState(0)

  const onDrop = useCallback(
    async (acceptedFiles: File[]) => {
      setIsUploading(true)
      setProgress(0)

      for (const file of acceptedFiles) {
        const formData = new FormData()
        formData.append("file", file)
        formData.append("taskId", taskId)

        try {
          const response = await fetch("/api/upload", {
            method: "POST",
            body: formData,
          })

          if (!response.ok) throw new Error("Upload failed")

          const data = await response.json()
          onUploadComplete(data.fileUrl, data.fileName)
          setProgress((prev) => prev + 100 / acceptedFiles.length)
        } catch (error) {
          console.error("Upload error:", error)
        }
      }

      setIsUploading(false)
      setProgress(100)
    },
    [taskId, onUploadComplete]
  )

  const { getRootProps, getInputProps, isDragActive } = useDropzone({
    onDrop,
    accept: generateClientDropzoneAccept([
      "image/*",
      "application/pdf",
      "text/*",
    ]),
    maxFiles: 5,
  })

  return (
    <div>
      <div
        {...getRootProps()}
        className={`cursor-pointer rounded-lg border-2 border-dashed p-6 text-center transition-colors ${
          isDragActive
            ? "border-primary bg-primary/5"
            : "border-muted-foreground/25 hover:border-muted-foreground/50"
        }`}
      >
        <input {...getInputProps()} />
        {isUploading ? (
          <div className="space-y-2">
            <p className="text-sm text-muted-foreground">Uploading...</p>
            <Progress value={progress} className="h-2" />
          </div>
        ) : isDragActive ? (
          <p className="text-sm text-muted-foreground">Drop files here</p>
        ) : (
          <div className="space-y-2">
            <svg
              className="mx-auto h-8 w-8 text-muted-foreground"
              fill="none"
              viewBox="0 0 24 24"
              stroke="currentColor"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
              />
            </svg>
            <p className="text-sm text-muted-foreground">
              Drag & drop or click to upload
            </p>
            <p className="text-xs text-muted-foreground">
              Images, PDFs, text files (max 8MB)
            </p>
          </div>
        )}
      </div>
    </div>
  )
}

▶ 示例:上传 API Route

TYPESCRIPT
// src/app/api/upload/route.ts
import { NextResponse } from "next/server"
import { writeFile, mkdir } from "fs/promises"
import path from "path"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/db"

export async function POST(request: Request) {
  const session = await auth()
  if (!session?.user?.id) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
  }

  try {
    const formData = await request.formData()
    const file = formData.get("file") as File
    const taskId = formData.get("taskId") as string

    if (!file || !taskId) {
      return NextResponse.json(
        { error: "File and taskId are required" },
        { status: 400 }
      )
    }

    const task = await prisma.task.findUnique({
      where: { id: taskId },
      select: { id: true },
    })

    if (!task) {
      return NextResponse.json({ error: "Task not found" }, { status: 404 })
    }

    const bytes = await file.arrayBuffer()
    const buffer = Buffer.from(bytes)

    const uploadDir = path.join(process.cwd(), "public", "uploads")
    await mkdir(uploadDir, { recursive: true })

    const uniqueName = `${Date.now()}-${file.name.replace(/[^a-zA-Z0-9.-]/g, "_")}`
    const filePath = path.join(uploadDir, uniqueName)
    await writeFile(filePath, buffer)

    const fileUrl = `/uploads/${uniqueName}`

    await prisma.attachment.create({
      data: {
        fileName: file.name,
        fileUrl,
        fileSize: file.size,
        mimeType: file.type,
        taskId,
        uploaderId: session.user.id,
      },
    })

    return NextResponse.json({
      fileUrl,
      fileName: file.name,
      fileSize: file.size,
    })
  } catch (error) {
    console.error("Upload error:", error)
    return NextResponse.json(
      { error: "Upload failed" },
      { status: 500 }
    )
  }
}

7. 搜索筛选与分页

(1) URL 搜索参数架构

100%
graph LR
    A[URL Search Params] --> B[Server Component]
    B --> C[Prisma Query]
    C --> D[Filtered Results]
    D --> E[shadcn/ui DataTable]
    E -->|User interaction| F[Update URL params]
    F -->|Next.js navigation| A

    style A fill:#cce5ff
    style E fill:#d4edda
    style F fill:#ffeeba

▶ 示例:任务列表页搜索与分页

TSX
// src/app/(dashboard)/dashboard/tasks/page.tsx
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { TasksDataTable } from "@/components/tasks/tasks-data-table"
import { TasksToolbar } from "@/components/tasks/tasks-toolbar"

interface TasksPageProps {
  searchParams: Promise<{
    search?: string
    status?: string
    priority?: string
    projectId?: string
    page?: string
    pageSize?: string
  }>
}

export default async function TasksPage({ searchParams }: TasksPageProps) {
  const session = await auth()
  const params = await searchParams

  const search = params.search || ""
  const status = params.status || ""
  const priority = params.priority || ""
  const projectId = params.projectId || ""
  const page = parseInt(params.page || "1", 10)
  const pageSize = parseInt(params.pageSize || "10", 10)

  const where: Record<string, unknown> = {
    project: {
      organization: {
        users: { some: { id: session?.user?.id } },
      },
    },
  }

  if (search) {
    where.OR = [
      { title: { contains: search, mode: "insensitive" } },
      { description: { contains: search, mode: "insensitive" } },
    ]
  }

  if (status) {
    where.status = status
  }

  if (priority) {
    where.priority = priority
  }

  if (projectId) {
    where.projectId = projectId
  }

  const [tasks, totalCount] = await Promise.all([
    prisma.task.findMany({
      where,
      include: {
        project: { select: { name: true, color: true } },
        assignee: { select: { name: true, image: true } },
        _count: { select: { comments: true, attachments: true } },
      },
      orderBy: { createdAt: "desc" },
      skip: (page - 1) * pageSize,
      take: pageSize,
    }),
    prisma.task.count({ where }),
  ])

  const totalPages = Math.ceil(totalCount / pageSize)

  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Tasks</h1>
        <p className="text-muted-foreground">
          {totalCount} total tasks · Page {page} of {totalPages}
        </p>
      </div>

      <TasksToolbar
        currentSearch={search}
        currentStatus={status}
        currentPriority={priority}
        currentProjectId={projectId}
      />

      <TasksDataTable
        tasks={tasks}
        currentPage={page}
        totalPages={totalPages}
        pageSize={pageSize}
        totalCount={totalCount}
      />
    </div>
  )
}

▶ 示例:DataTable 组件

TSX
// src/components/tasks/tasks-data-table.tsx
import Link from "next/link"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"

interface TaskRow {
  id: string
  title: string
  status: string
  priority: string
  project: { name: string; color: string }
  assignee: { name: string; image: string | null } | null
  _count: { comments: number; attachments: number }
  createdAt: Date
}

interface TasksDataTableProps {
  tasks: TaskRow[]
  currentPage: number
  totalPages: number
  pageSize: number
  totalCount: number
}

const statusStyles: 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",
}

const priorityStyles: Record<string, string> = {
  LOW: "bg-gray-100 text-gray-600",
  MEDIUM: "bg-blue-100 text-blue-600",
  HIGH: "bg-orange-100 text-orange-600",
  URGENT: "bg-red-100 text-red-600",
}

export function TasksDataTable({
  tasks,
  currentPage,
  totalPages,
  pageSize,
  totalCount,
}: TasksDataTableProps) {
  return (
    <div className="rounded-lg border">
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead className="w-[400px]">Task</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Priority</TableHead>
            <TableHead>Project</TableHead>
            <TableHead>Assignee</TableHead>
            <TableHead className="text-right">Comments</TableHead>
            <TableHead className="text-right">Date</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {tasks.length === 0 ? (
            <TableRow>
              <TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
                No tasks found matching your filters.
              </TableCell>
            </TableRow>
          ) : (
            tasks.map((task) => (
              <TableRow key={task.id}>
                <TableCell>
                  <Link
                    href={`/dashboard/tasks/${task.id}`}
                    className="font-medium hover:underline"
                  >
                    {task.title}
                  </Link>
                </TableCell>
                <TableCell>
                  <Badge
                    variant="secondary"
                    className={statusStyles[task.status]}
                  >
                    {task.status.replace("_", " ")}
                  </Badge>
                </TableCell>
                <TableCell>
                  <Badge
                    variant="secondary"
                    className={priorityStyles[task.priority]}
                  >
                    {task.priority}
                  </Badge>
                </TableCell>
                <TableCell>
                  <div className="flex items-center gap-2">
                    <div
                      className="h-2 w-2 rounded-full"
                      style={{ backgroundColor: task.project.color }}
                    />
                    <span className="text-sm">{task.project.name}</span>
                  </div>
                </TableCell>
                <TableCell>
                  {task.assignee ? (
                    <div className="flex items-center gap-2">
                      <Avatar className="h-6 w-6">
                        <AvatarImage src={task.assignee.image ?? undefined} />
                        <AvatarFallback className="text-[10px]">
                          {task.assignee.name[0]}
                        </AvatarFallback>
                      </Avatar>
                      <span className="text-sm">{task.assignee.name}</span>
                    </div>
                  ) : (
                    <span className="text-sm text-muted-foreground">Unassigned</span>
                  )}
                </TableCell>
                <TableCell className="text-right">{task._count.comments}</TableCell>
                <TableCell className="text-right text-sm text-muted-foreground">
                  {task.createdAt.toLocaleDateString("en-US", {
                    month: "short",
                    day: "numeric",
                  })}
                </TableCell>
              </TableRow>
            ))
          )}
        </TableBody>
      </Table>

      <div className="flex items-center justify-between border-t p-4">
        <p className="text-sm text-muted-foreground">
          Showing {((currentPage - 1) * pageSize) + 1} to{" "}
          {Math.min(currentPage * pageSize, totalCount)} of {totalCount} tasks
        </p>
        <div className="flex items-center gap-2">
          <Button
            variant="outline"
            size="sm"
            disabled={currentPage <= 1}
            asChild={currentPage > 1}
          >
            {currentPage > 1 ? (
              <Link href={`/dashboard/tasks?page=${currentPage - 1}`}>
                Previous
              </Link>
            ) : (
              <span>Previous</span>
            )}
          </Button>
          <div className="flex items-center gap-1">
            {Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
              const pageNum = i + 1
              return (
                <Button
                  key={pageNum}
                  variant={pageNum === currentPage ? "default" : "outline"}
                  size="sm"
                  className="h-8 w-8 p-0"
                  asChild
                >
                  <Link href={`/dashboard/tasks?page=${pageNum}`}>
                    {pageNum}
                  </Link>
                </Button>
              )
            })}
          </div>
          <Button
            variant="outline"
            size="sm"
            disabled={currentPage >= totalPages}
            asChild={currentPage < totalPages}
          >
            {currentPage < totalPages ? (
              <Link href={`/dashboard/tasks?page=${currentPage + 1}`}>
                Next
              </Link>
            ) : (
              <span>Next</span>
            )}
          </Button>
        </div>
      </div>
    </div>
  )
}

8. 完整示例:任务看板完整页面

TSX
// src/app/(dashboard)/dashboard/projects/[id]/page.tsx
import { Suspense } from "react"
import { prisma } from "@/lib/db"
import { auth } from "@/lib/auth"
import { notFound } from "next/navigation"
import { TaskBoard } from "@/components/tasks/task-board"
import { CreateTaskForm } from "@/components/tasks/create-task-form"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"

interface ProjectBoardPageProps {
  params: Promise<{ id: string }>
}

export default async function ProjectBoardPage({ params }: ProjectBoardPageProps) {
  const { id } = await params
  const session = await auth()

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

  if (!project) {
    notFound()
  }

  const tasks = await prisma.task.findMany({
    where: { projectId: id },
    orderBy: { order: "asc" },
    include: {
      assignee: { select: { name: true, image: true } },
      _count: { select: { comments: true, attachments: true } },
    },
  })

  const statusCounts = {
    TODO: tasks.filter((t) => t.status === "TODO").length,
    IN_PROGRESS: tasks.filter((t) => t.status === "IN_PROGRESS").length,
    DONE: tasks.filter((t) => t.status === "DONE").length,
  }

  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">
              {project.status}
            </Badge>
          </div>
          <p className="mt-1 text-sm text-muted-foreground">
            {tasks.length} tasks · {statusCounts.TODO} todo ·{" "}
            {statusCounts.IN_PROGRESS} in progress · {statusCounts.DONE} done
          </p>
        </div>
      </div>

      <Suspense fallback={<div className="h-12 animate-pulse rounded-lg bg-muted" />}>
        <CreateTaskForm projectId={project.id} members={[]} />
      </Suspense>

      <TaskBoard tasks={tasks} />
    </div>
  )
}

预期输出(浏览器访问 /dashboard/projects/[id]):

TEXT 📖 仅展示
→ 看板三列:TODO (8) | IN PROGRESS (5) | DONE (7)
→ 点击 "+" 按钮弹出快速创建表单 → 输入标题 → 回车 → 任务即时出现在 TODO 列(乐观更新)
→ 点击任务卡片的 ▶ 按钮 → 任务移动到 IN PROGRESS
→ 点击删除 → 弹出确认弹窗 → 确认删除 → 任务消失
→ 拖拽文件到上传区域 → 显示进度条 → 附件关联到任务
→ 搜索 "auth" → URL 参数更新 → DataTable 过滤显示匹配任务

❓ 常见问题

Q Server Actions 和 API Routes 如何选择?
A 表单提交 + 缓存刷新用 Server Actions(代码少、类型安全、无需 API 端点)。第三方集成、Webhook、移动端 API 用 API Routes。Server Actions 是 Next.js 推荐的表单处理方式,API Routes 用于非浏览器客户端。
Q useOptimistic 和普通的 useState 有什么区别?
A useOptimistic 是 React 19 新 Hook,专门设计用于 Server Actions 的乐观更新模式。它在 Server Action 执行期间立即更新 UI,如果 Server Action 失败则自动回滚到真实数据。useState + 手动回滚需要更多样板代码且容易出错。
Q uploadthing 和本地文件上传哪个更好?
A uploadthing 提供 CDN、图片优化、安全校验,适合生产环境。本地 /uploads/ 方案开发简单但不适合上生产(无 CDN、存储空间有限、备份困难)。TaskFlow 先用本地上传作为开发方案,生产环境切换 uploadthing。
Q URL 搜索参数的分页为什么不用客户端分页?
A URL 搜索参数分页是"真分页"(服务端分页),只加载当前页数据,适合大数据量。客户端分页一次性加载所有数据,首次加载慢且浪费带宽。TaskFlow 可能有上万条任务,服务端分页是唯一选择。
Q Zod 校验在 Server Action 中的作用是什么?
A 双重保障——浏览器端验证(HTML5 required + 前端校验)防误操作,服务端 Zod 校验防恶意请求。Zod 还提供类型安全的 safeParse,自动生成类型错误信息,比手写 if-else 校验更可靠。
Q 删除任务为什么要用 useActionState 而不是直接调用 deleteTask?
A useActionState 是 React 19 为 Server Actions 设计的 Hook,它提供 isPending 状态(禁用按钮防重复提交)和 action 包装函数(自动处理 formData)。对比直接调用:useActionState 更安全(防 CSRF)且 UI 反馈更自然。
Q revalidateTag 和 revalidatePath 有什么区别?
A revalidateTag("tasks") 按缓存标签失效,所有带 { next: { tags: ["tasks"] } } 的 fetch 都被刷新。revalidatePath("/dashboard/tasks") 按路径刷新。推荐用 Tag 策略——更精细控制,不依赖路径。TaskFlow 所有任务 fetch 都打上 "tasks" 标签。

📖 小节

📝 作业

  1. 基础题(⭐):在 TaskFlow 项目中实现任务快速创建表单(标题 + projectId),使用 Server Action + optimistic update。验证:点击创建后任务即时出现在看板 TODO 列。

  2. 进阶题(⭐⭐):在 DataTable 中添加"批量操作"功能——选择多行任务后批量更新状态(TODO → IN PROGRESS)或批量删除。使用 useOptimistic 实现批量选中态的乐观更新。编写 bulkUpdateTasks Server Action 处理数组入参。

  3. 挑战题(⭐⭐⭐):实现完整的拖拽排序(使用 @dnd-kit/core 或原生 HTML5 Drag & Drop API):三列之间自由拖拽任务卡片;拖拽时显示 placeholder;松手后调用 reorderTask 更新状态和排序;使用 useOptimistic 实现拖拽后的即时排序反馈;处理拖拽冲突(同一任务同时被两人拖拽时的乐观锁)。

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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