Next.js: 综合项目:CRUD 与 Server Actions
最后更新:2026-08-26
任务管理是 SaaS 平台的核心——创建、编辑、删除、拖拽排序、文件上传、搜索筛选,一套完整的 CRUD 系统让用户高效管理工作流。
1. 你将学到
- 使用 Server Actions 实现 Task CRUD(create / update / delete)
- Zod 校验 +
useOptimistic乐观更新模式 useActionState确认弹窗与错误处理- 拖拽排序实现 Todo / In Progress / Done 三列切换
- uploadthing 文件上传与 Attachment 模型关联
- URL 搜索参数 + shadcn/ui DataTable 实现搜索筛选分页
2. 一个项目经理的真实故事
(1) 痛点:50 人团队每天浪费 3 小时在任务管理上
Bob 是 Acme Corp 的项目经理,管理着 5 个项目 200+ 任务。团队每天花大量时间在:创建任务(表单 10 个字段,反复填)、拖拽调整状态(白板贴便利贴)、找人要文件(微信传来传去)、搜索任务(翻 Excel 表格)。Alice 统计过——50 人团队每人每天浪费 36 分钟在低效的任务管理上,相当于每月 600 小时生产力损失。
(2) Server Actions + 乐观更新 + 拖拽的解法
用 Server Actions 实现零刷新 CRUD + useOptimistic 即时反馈 + 拖拽式状态切换。
// 一行 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) 操作架构
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 定义
// 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 模式
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 乐观更新
// 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 确认删除
// 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) 看板架构
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
▶ 示例:任务看板三列布局
// 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>
)
}
▶ 示例:任务卡片组件
// 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) 上传架构
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 配置
// 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
// src/app/api/uploadthing/core.ts
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "@/lib/uploadthing"
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
})
▶ 示例:文件上传组件
// 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
// 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 搜索参数架构
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
▶ 示例:任务列表页搜索与分页
// 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 组件
// 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. 完整示例:任务看板完整页面
// 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]):
→ 看板三列:TODO (8) | IN PROGRESS (5) | DONE (7)
→ 点击 "+" 按钮弹出快速创建表单 → 输入标题 → 回车 → 任务即时出现在 TODO 列(乐观更新)
→ 点击任务卡片的 ▶ 按钮 → 任务移动到 IN PROGRESS
→ 点击删除 → 弹出确认弹窗 → 确认删除 → 任务消失
→ 拖拽文件到上传区域 → 显示进度条 → 附件关联到任务
→ 搜索 "auth" → URL 参数更新 → DataTable 过滤显示匹配任务
❓ 常见问题
/uploads/ 方案开发简单但不适合上生产(无 CDN、存储空间有限、备份困难)。TaskFlow 先用本地上传作为开发方案,生产环境切换 uploadthing。safeParse,自动生成类型错误信息,比手写 if-else 校验更可靠。useActionState 是 React 19 为 Server Actions 设计的 Hook,它提供 isPending 状态(禁用按钮防重复提交)和 action 包装函数(自动处理 formData)。对比直接调用:useActionState 更安全(防 CSRF)且 UI 反馈更自然。revalidateTag("tasks") 按缓存标签失效,所有带 { next: { tags: ["tasks"] } } 的 fetch 都被刷新。revalidatePath("/dashboard/tasks") 按路径刷新。推荐用 Tag 策略——更精细控制,不依赖路径。TaskFlow 所有任务 fetch 都打上 "tasks" 标签。📖 小节
- Server Actions 用
"use server"指令直接在服务端执行函数,避免手动 API Route - Zod 提供类型安全的表单校验,配合 Server Actions 错误返回实现完整验证链路
useOptimistic实现即时 UI 更新,Server Action 失败时自动回滚useActionState管理确认弹窗状态,提供isPending和自动 formData 包装- 三列看板通过
reorderTaskServer Action 实现拖拽状态切换 - uploadthing + 本地
/uploads/双方案文件上传,支持图片/PDF/文本 - URL 搜索参数实现真分页 + 搜索 + 筛选,DataTable 展示 7 列完整任务信息
revalidateTag("tasks")统一缓存失效策略,确保 CRUD 后 UI 实时更新
📝 作业
-
基础题(⭐):在 TaskFlow 项目中实现任务快速创建表单(标题 + projectId),使用 Server Action + optimistic update。验证:点击创建后任务即时出现在看板 TODO 列。
-
进阶题(⭐⭐):在 DataTable 中添加"批量操作"功能——选择多行任务后批量更新状态(TODO → IN PROGRESS)或批量删除。使用
useOptimistic实现批量选中态的乐观更新。编写bulkUpdateTasksServer Action 处理数组入参。 -
挑战题(⭐⭐⭐):实现完整的拖拽排序(使用
@dnd-kit/core或原生 HTML5 Drag & Drop API):三列之间自由拖拽任务卡片;拖拽时显示 placeholder;松手后调用reorderTask更新状态和排序;使用useOptimistic实现拖拽后的即时排序反馈;处理拖拽冲突(同一任务同时被两人拖拽时的乐观锁)。