React: 综合实战:SaaS 看板应用
最后更新:2026-08-26
本课是 30 课 React 教程的收官之战。你将综合运用前面学到的全部知识——Next.js App Router、数据获取、UI 组件库、认证鉴权、CI/CD 部署——从零构建一个多租户 SaaS 看板(Kanban)应用。这不是一个 Demo 项目,而是一个可以上线运营的真实产品。
1. 项目概览
(1) 功能说明
SaaS Kanban 是一个面向团队的轻量级项目管理工具,核心功能包括:
| 功能模块 | 说明 | 涉及技术 |
|---|---|---|
| 看板面板 | 拖拽卡片在不同列间移动 | @dnd-kit 拖拽库 |
| 任务管理 | 创建/编辑/删除/分配任务 | Server Action + 数据库 |
| 多租户 | 每个团队独立的工作区 | 租户 ID 数据隔离 |
| 用户认证 | 邮箱密码登录 + Google OAuth | NextAuth.js |
| 实时更新 | 拖拽后自动同步到服务端 | Server Action optimistic update |
| 数据统计 | 看板状态统计柱状图 | Recharts 图表库 |
| 部署 | 自动化 CI/CD 流水线 | Vercel + GitHub Actions |
(2) 技术架构
项目根目录/
├── app/ # App Router 路由
│ ├── (auth)/ # 认证相关页面
│ │ ├── login/ # 登录页
│ │ ├── register/ # 注册页
│ │ └── layout.tsx # 认证页面布局
│ ├── (dashboard)/ # 仪表盘(需登录)
│ │ ├── board/ # 看板页面
│ │ │ ├── [id]/ # 具体看板
│ │ │ └── page.tsx # 看板列表
│ │ ├── layout.tsx # 仪表盘布局(侧边栏)
│ │ └── page.tsx # 首页重定向
│ ├── api/ # Route Handler
│ │ ├── auth/ # NextAuth API
│ │ └── boards/ # 看板数据 API
│ └── layout.tsx # 全局布局
├── components/ # 可复用组件
│ ├── board/ # 看板组件
│ ├── ui/ # UI 通用组件
│ └── providers.tsx # Ant Design Provider
├── lib/ # 工具函数
│ ├── prisma.ts # 数据库客户端
│ └── auth.ts # NextAuth 配置
├── prisma/ # 数据库 Schema
│ └── schema.prisma
├── middleware.ts # 路由守卫
├── next.config.ts
└── package.json
2. 技术架构图
| 架构层 | 技术 | 职责 |
|---|---|---|
| 前端展示层 | Next.js + Ant Design | SSR/SSG、UI 组件、交互逻辑 |
| API 层 | Route Handler + Server Action | 数据接口、业务逻辑、表单提交 |
| 数据访问层 | Prisma ORM | 类型安全的数据库查询 |
| 存储层 | PostgreSQL | 持久化数据存储 |
| 认证层 | NextAuth.js | 邮箱密码 + Google OAuth |
| 部署层 | Vercel + GitHub Actions | CI/CD 自动化部署 |
SaaS Kanban 的整体架构分为四层:前端展示层(Next.js + Ant Design)、API 层(Route Handler + Server Action)、数据访问层(Prisma ORM)、存储层(PostgreSQL)。认证使用 NextAuth.js 统一管理,部署通过 Vercel + GitHub Actions 自动化。
flowchart TD
A[浏览器] --> B[Next.js App]
B --> C{路由分发}
C -->|公开路由| D[登录/注册]
C -->|保护路由| E[看板页面]
E --> F[Server Component<br/>数据获取]
E --> G[Client Component<br/>拖拽交互]
F --> H[Prisma ORM]
G --> I[Server Action<br/>操作同步]
H --> J[(PostgreSQL)]
I --> J
D --> K[NextAuth.js]
K --> J
B --> L[Middleware]
L -->|未登录| D
L -->|已登录| E
B --> M[Vercel 部署]
M --> N[GitHub Actions CI/CD]
3. 项目搭建与数据库设计
(1) 初始化项目
# 创建 Next.js 项目
npx create-next-app@latest saas-kanban --typescript --tailwind --app
# 安装核心依赖
cd saas-kanban
# UI 组件库
npm install antd @ant-design/icons
# 拖拽功能
npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
# 认证
npm install next-auth @auth/prisma-adapter
# 数据库
npm install prisma @prisma/client
npx prisma init
# 图表
npm install recharts
(2) 数据库 Schema
| 数据模型 | 关键字段 | 关联关系 | 说明 |
|---|---|---|---|
| User | email, name, image | → Board[], Task[] | 用户拥有多个看板和任务 |
| Board | title, description | → Column[] | 看板包含多个列 |
| Column | title, position | → Task[] | 列包含多个任务卡片 |
| Task | title, priority, dueDate | → User(assignee) | 任务可分配给用户 |
| Account/Session | provider, token | → User | NextAuth 认证所需 |
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// 用户模型
model User {
id String @id @default(cuid())
name String?
email String @unique
emailVerified DateTime?
image String?
accounts Account[]
sessions Session[]
boards Board[] // 用户创建的看板
tasks Task[] // 用户分配的任务
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 看板模型(多租户通过关联到用户实现数据隔离)
model Board {
id String @id @default(cuid())
title String
color String @default("#1677ff")
ownerId String
owner User @relation(fields: [ownerId], references: [id])
columns Column[]
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 看板列
model Column {
id String @id @default(cuid())
title String
position Int // 排序位置
boardId String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
tasks Task[]
}
// 任务卡片
model Task {
id String @id @default(cuid())
title String
description String?
priority Priority @default(middle)
assigneeId String?
assignee User? @relation(fields: [assigneeId], references: [id])
columnId String
column Column @relation(fields: [columnId], references: [id], onDelete: Cascade)
boardId String
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
position Int // 列内排序位置
dueDate DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Priority {
urgent
high
middle
low
}
▶ 示例 1:NextAuth.js 认证配置
// lib/auth.ts - NextAuth 完整配置
import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import CredentialsProvider from 'next-auth/providers/credentials'
import { PrismaAdapter } from '@auth/prisma-adapter'
import { prisma } from './prisma'
function verifyPassword(plain: string, hashed: string): boolean {
// 生产环境请用 bcryptjs:npm i bcryptjs → bcrypt.compare(plain, hashed)
return plain === hashed
}
export const { handlers, auth, signIn, signOut } = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
// Google OAuth
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
// 邮箱密码登录
CredentialsProvider({
name: 'credentials',
credentials: {
email: { label: '邮箱', type: 'email' },
password: { label: '密码', type: 'password' },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null
const user = await prisma.user.findUnique({
where: { email: credentials.email as string }
})
if (!user || !user.password) return null
const isValid = verifyPassword(
credentials.password as string,
user.password
)
if (!isValid) return null
return user
},
}),
],
pages: {
signIn: '/login',
error: '/login',
},
callbacks: {
// 将用户 ID 和租户信息注入 JWT
async jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
// 将 JWT 信息注入 Session
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string
}
return session
},
},
session: {
strategy: 'jwt',
},
})
// lib/prisma.ts - 数据库客户端单例
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
4. Step-by-Step 编码
下面按照功能模块逐步实现看板应用的核心代码。每个步骤对应一个完整的功能块,你可以按顺序复制到项目中运行。
(1) 看板页面布局与数据获取
看板页面的核心结构是"三栏布局":左侧侧边栏(看板列表)、中间主区域(列标题 + 任务卡片)。这是一个典型的 Server Component + Client Component 分层:外层 Server Component 获取看板数据,内层 Client Component 处理拖拽交互。
▶ 示例 2:看板页面 Server Component 数据获取
// app/(dashboard)/board/[id]/page.tsx
// Server Component:获取看板、列、任务数据
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import BoardClient from './BoardClient'
interface BoardPageProps {
params: { id: string }
}
async function BoardPage({ params }: BoardPageProps) {
const session = await auth()
// 未登录重定向
if (!session?.user?.id) redirect('/login')
// 获取看板数据(含列和任务)
const board = await prisma.board.findUnique({
where: {
id: params.id,
ownerId: session.user.id, // 多租户数据隔离
},
include: {
columns: {
orderBy: { position: 'asc' },
include: {
tasks: {
orderBy: { position: 'asc' },
include: { assignee: { select: { id: true, name: true, image: true } } },
},
},
},
},
})
if (!board) redirect('/board')
// 序列化 Date 字段(Server Component 传递给 Client Component 时需要)
const serializedBoard = JSON.parse(JSON.stringify(board))
return <BoardClient board={serializedBoard} />
}
export default BoardPage
// app/(dashboard)/board/[id]/BoardClient.tsx
// Client Component:拖拽交互核心
'use client'
import { useState, useCallback } from 'react'
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Layout } from 'antd'
import BoardColumn from './BoardColumn'
import TaskCard from './TaskCard'
const { Content } = Layout
interface BoardData {
id: string
title: string
columns: ColumnData[]
}
interface ColumnData {
id: string
title: string
tasks: TaskData[]
}
interface TaskData {
id: string
title: string
description?: string
priority: string
position: number
assignee?: { id: string; name: string; image?: string }
}
function BoardClient({ board }: { board: BoardData }) {
const [activeTask, setActiveTask] = useState<TaskData | null>(null)
const [columns, setColumns] = useState(board.columns)
// 配置传感器(指针 + 键盘)
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor)
)
// 拖拽开始:记录被拖动的任务
const handleDragStart = useCallback((event: any) => {
const { active } = event
const task = columns
.flatMap(col => col.tasks)
.find(t => t.id === active.id)
setActiveTask(task || null)
}, [columns])
// 拖拽结束:更新任务所属列和排序位置
const handleDragEnd = useCallback(async (event: any) => {
const { active, over } = event
if (!over || active.id === over.id) {
setActiveTask(null)
return
}
const sourceColumn = columns.find(col =>
col.tasks.some(t => t.id === active.id)
)
const targetColumn = columns.find(col =>
col.tasks.some(t => t.id === over.id) ||
col.id === over.id
)
if (!sourceColumn || !targetColumn) {
setActiveTask(null)
return
}
const task = sourceColumn.tasks.find(t => t.id === active.id)!
const newSourceTasks = sourceColumn.tasks.filter(t => t.id !== active.id)
if (sourceColumn.id === targetColumn.id) {
// 同一列内排序
const overIndex = targetColumn.tasks.findIndex(t => t.id === over.id)
const newTasks = [...newSourceTasks]
newTasks.splice(overIndex, 0, task)
setColumns(prev => prev.map(col =>
col.id === sourceColumn.id ? { ...col, tasks: newTasks } : col
))
} else {
// 跨列移动
const overIndex = targetColumn.tasks.findIndex(t => t.id === over.id)
const newTargetTasks = [...targetColumn.tasks]
newTargetTasks.splice(overIndex, 0, task)
setColumns(prev => prev.map(col => {
if (col.id === sourceColumn.id) return { ...col, tasks: newSourceTasks }
if (col.id === targetColumn.id) return { ...col, tasks: newTargetTasks }
return col
}))
}
// 同步到服务端
await fetch('/api/tasks/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
taskId: active.id,
targetColumnId: targetColumn.id,
position: targetColumn.tasks.findIndex(t => t.id === over.id),
}),
})
setActiveTask(null)
}, [columns])
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<Content style={{ display: 'flex', gap: 16, padding: 24, overflowX: 'auto' }}>
{columns.map(column => (
<BoardColumn key={column.id} column={column} />
))}
</Content>
<DragOverlay>
{activeTask ? <TaskCard task={activeTask} isOverlay /> : null}
</DragOverlay>
</DndContext>
)
}
export default BoardClient
(2) 看板列与任务卡片组件
每个看板列是一个独立组件,包含列标题、任务列表、列颜色标识。任务卡片展示标题、优先级标签、负责人头像和到期时间。使用 @dnd-kit 的 useSortable hook 实现拖拽能力。
▶ 示例 3:列组件与任务卡片
// board/BoardColumn.tsx - 看板列组件
'use client'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import { Card, Tag, Typography } from 'antd'
import TaskCard from './TaskCard'
const { Text } = Typography
interface ColumnProps {
column: {
id: string
title: string
tasks: any[]
}
}
const columnColors: Record<string, string> = {
'待办': '#f0f0f0',
'进行中': '#e6f4ff',
'已完成': '#f6ffed',
}
const columnHeaderColors: Record<string, string> = {
'待办': '#d9d9d9',
'进行中': '#1677ff',
'已完成': '#52c41a',
}
function BoardColumn({ column }: ColumnProps) {
// 设置可放置区域
const { setNodeRef, isOver } = useDroppable({ id: column.id })
return (
<div
ref={setNodeRef}
style={{
minWidth: 300,
maxWidth: 360,
flex: 1,
background: isOver ? '#e6f4ff' : (columnColors[column.title] || '#fafafa'),
borderRadius: 12,
padding: 12,
transition: 'background 0.2s',
}}
>
{/* 列标题 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<Text strong style={{ fontSize: 16 }}>
{column.title}
</Text>
<Tag color={columnHeaderColors[column.title] || 'default'}>
{column.tasks.length}
</Tag>
</div>
{/* 可排序的任务列表 */}
<SortableContext items={column.tasks.map(t => t.id)} strategy={verticalListSortingStrategy}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, minHeight: 100 }}>
{column.tasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
</div>
)
}
export default BoardColumn
// board/TaskCard.tsx - 可拖拽任务卡片
'use client'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Card, Tag, Avatar, Typography, Tooltip } from 'antd'
import { CalendarOutlined } from '@ant-design/icons'
const { Text } = Typography
const priorityColors: Record<string, string> = {
urgent: 'red',
high: 'orange',
middle: 'blue',
low: 'green',
}
const priorityLabels: Record<string, string> = {
urgent: '紧急',
high: '高',
middle: '中',
low: '低',
}
interface TaskCardProps {
task: {
id: string
title: string
description?: string
priority: string
dueDate?: string
assignee?: { id: string; name: string; image?: string }
position: number
}
isOverlay?: boolean
}
function TaskCard({ task, isOverlay }: TaskCardProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: task.id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
cursor: isOverlay ? 'grabbing' : 'grab',
}
return (
<Card
ref={setNodeRef}
style={style}
size="small"
{...attributes}
{...listeners}
hoverable
bodyStyle={{ padding: 12 }}
>
{/* 优先级标签 */}
<div style={{ marginBottom: 8 }}>
<Tag color={priorityColors[task.priority]} style={{ fontSize: 11 }}>
{priorityLabels[task.priority]}
</Tag>
</div>
{/* 任务标题 */}
<Text strong style={{ fontSize: 14, display: 'block', marginBottom: 4 }}>
{task.title}
</Text>
{/* 任务描述预览 */}
{task.description && (
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
{task.description.slice(0, 60)}...
</Text>
)}
{/* 底部信息:到期时间 + 负责人 */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{task.dueDate && (
<Tooltip title={new Date(task.dueDate).toLocaleDateString('zh-CN')}>
<Text type="secondary" style={{ fontSize: 11 }}>
<CalendarOutlined /> {new Date(task.dueDate).toLocaleDateString('zh-CN')}
</Text>
</Tooltip>
)}
{task.assignee && (
<Tooltip title={task.assignee.name}>
<Avatar src={task.assignee.image} size={24}>
{task.assignee.name?.[0]}
</Avatar>
</Tooltip>
)}
</div>
</Card>
)
}
export default TaskCard
// app/api/tasks/move/route.ts - 拖拽同步 API
import { prisma } from '@/lib/prisma'
export async function PUT(request: Request) {
try {
const { taskId, targetColumnId, position } = await request.json()
// 更新任务所属列和排序位置
const task = await prisma.task.update({
where: { id: taskId },
data: {
columnId: targetColumnId,
position,
},
})
return Response.json({ success: true, task })
} catch (error) {
return Response.json({ error: 'Failed to move task' }, { status: 500 })
}
}
(3) 看板列表与数据统计
看板列表页展示用户创建的所有看板,每个看板以卡片形式显示名称、创建时间和任务数量。数据统计使用 Recharts 柱状图展示各优先级任务的分布情况,帮助团队直观了解工作负载。
▶ 示例 4:看板列表与统计图表
// app/(dashboard)/page.tsx - 仪表盘首页(看板列表 + 统计)
import { auth } from '@/lib/auth'
import { prisma } from '@/lib/prisma'
import { redirect } from 'next/navigation'
import DashboardClient from './DashboardClient'
async function DashboardPage() {
const session = await auth()
if (!session?.user?.id) redirect('/login')
// 获取用户的看板列表
const boards = await prisma.board.findMany({
where: { ownerId: session.user.id },
include: {
_count: { select: { tasks: true } },
},
orderBy: { updatedAt: 'desc' },
})
// 获取各优先级任务数量统计
const taskPriorityCounts = await prisma.task.groupBy({
by: ['priority'],
where: {
board: { ownerId: session.user.id },
},
_count: true,
})
const stats = {
totalTasks: taskPriorityCounts.reduce((sum, item) => sum + item._count, 0),
priorityData: taskPriorityCounts.map(item => ({
priority: item.priority,
count: item._count,
})),
}
return (
<DashboardClient
boards={JSON.parse(JSON.stringify(boards))}
stats={JSON.parse(JSON.stringify(stats))}
/>
)
}
export default DashboardPage
// app/(dashboard)/DashboardClient.tsx
'use client'
import { Card, Row, Col, Statistic, Typography } from 'antd'
import { PlusOutlined, ProjectOutlined, CheckCircleOutlined } from '@ant-design/icons'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import Link from 'next/link'
const { Title } = Typography
const priorityLabels: Record<string, string> = {
urgent: '紧急',
high: '高',
middle: '中',
low: '低',
}
const priorityColors: Record<string, string> = {
urgent: '#ff4d4f',
high: '#fa8c16',
middle: '#1677ff',
low: '#52c41a',
}
function DashboardClient({ boards, stats }: { boards: any[], stats: any }) {
const chartData = stats.priorityData.map((d: any) => ({
name: priorityLabels[d.priority] || d.priority,
count: d.count,
fill: priorityColors[d.priority] || '#1677ff',
}))
return (
<div style={{ padding: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={3} style={{ margin: 0 }}>我的看板</Title>
<Link href="/board/new">
<Card
hoverable
style={{ width: 200, textAlign: 'center', borderStyle: 'dashed' }}
>
<PlusOutlined style={{ fontSize: 24, color: '#1677ff' }} />
<div style={{ marginTop: 8 }}>创建看板</div>
</Card>
</Link>
</div>
{/* 统计概览 */}
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card>
<Statistic
title="总任务数"
value={stats.totalTasks}
prefix={<ProjectOutlined />}
/>
</Card>
</Col>
<Col span={6}>
<Card>
<Statistic
title="看板数"
value={boards.length}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
</Row>
{/* 优先级分布图 */}
<Card title="任务优先级分布" style={{ marginBottom: 24 }}>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" name="数量" radius={[4, 4, 0, 0]}>
{chartData.map((entry: any, index: number) => (
<rect key={index} fill={entry.fill} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</Card>
{/* 看板列表 */}
<Row gutter={[16, 16]}>
{boards.map((board: any) => (
<Col key={board.id} span={8}>
<Link href={`/board/${board.id}`}>
<Card
hoverable
style={{ borderTop: `3px solid ${board.color || '#1677ff'}` }}
>
<Card.Meta
title={board.title}
description={`${board._count.tasks} 个任务`}
/>
</Card>
</Link>
</Col>
))}
</Row>
</div>
)
}
export default DashboardClient
▶ 示例 5:任务拖拽排序 Server Action
// app/actions/board-actions.ts - 拖拽排序服务端操作
'use server'
import { prisma } from '@/lib/prisma'
import { auth } from '@/lib/auth'
import { revalidatePath } from 'next/cache'
export async function moveTask(taskId: string, targetColumnId: string, newPosition: number) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const task = await prisma.task.findUnique({
where: { id: taskId },
include: { board: true },
})
if (!task || task.board.userId !== session.user.id) {
throw new Error('Task not found or no permission')
}
await prisma.$transaction(async (tx) => {
await tx.task.updateMany({
where: { columnId: targetColumnId, position: { gte: newPosition } },
data: { position: { increment: 1 } },
})
await tx.task.update({
where: { id: taskId },
data: { columnId: targetColumnId, position: newPosition },
})
})
revalidatePath(`/board/${task.boardId}`)
}
export async function createTask(columnId: string, title: string, priority: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const column = await prisma.column.findUnique({
where: { id: columnId },
include: { board: true },
})
if (!column || column.board.userId !== session.user.id) {
throw new Error('Column not found or no permission')
}
const maxPosition = await prisma.task.findMany({
where: { columnId },
orderBy: { position: 'desc' },
take: 1,
select: { position: true },
})
const task = await prisma.task.create({
data: {
title,
priority: priority as any,
columnId,
boardId: column.boardId,
position: (maxPosition[0]?.position ?? -1) + 1,
},
})
revalidatePath(`/board/${column.boardId}`)
return task
}
❓ 常见问题
Board 记录关联一个 ownerId,所有数据查询都带上 ownerId 过滤条件(如 where: { ownerId: session.user.id })。数据库层面不需要做多租户隔离(不需要 schema-per-tenant),应用层的条件查询就足够了。如果需要团队协作(多人共用一个看板),可以在 Board 上增加 Team 关联模型。DndContext + SortableContext + useSortable 模式是 @dnd-kit 最常用的组合。DATABASE_URL、NEXTAUTH_SECRET、GOOGLE_CLIENT_ID 等)。部署前运行 npx prisma generate 和 npx prisma db push 初始化数据库。本课第 28 节的 CI/CD 配置可以直接复用。members 字段区分 Owner/Editor/Viewer 角色;③ 通知系统——当卡片被分配、截止日期临近时推送邮件或浏览器通知。技术栈升级路线:Supabase Realtime + NextAuth.js RBAC + Web Push API。📖 小节
- 本课综合运用了前面 29 课的知识:App Router、数据获取、组件库、认证、CI/CD 部署
- SaaS Kanban 核心功能:看板面板 + 拖拽交互 + 多租户数据隔离 + 用户认证 + 数据统计
- 技术栈:Next.js 14 + Ant Design 5 + Prisma + PostgreSQL + @dnd-kit + NextAuth.js + Recharts
- 多租户通过
ownerId字段实现数据隔离,应用层查询条件过滤 - 拖拽交互使用 @dnd-kit 的 DndContext + SortableContext + useSortable 三位一体组合
- Server Component 负责数据获取,Client Component 负责拖拽交互,Server Action/API 负责数据同步
- 部署推荐 Vercel + Supabase PostgreSQL,通过 GitHub Actions 实现 CI/CD 自动化
- 这是一个可以上线运营的真实 SaaS 产品,不是 Demo 项目
📝 作业
- 按照本课步骤搭建项目骨架:创建 Next.js 项目、配置 Prisma 数据库 Schema、运行
npx prisma db push初始化数据表。在 Supabase 中创建一个免费的 PostgreSQL 数据库,将连接字符串配置到.env文件中。验证prisma db push成功执行。 - 实现 @dnd-kit 的看板拖拽功能:配置 DndContext(PointerSensor + KeyboardSensor)、实现 handleDragEnd 函数处理跨列移动和同列排序、通过 PUT API 同步拖拽结果到数据库。验证拖拽后刷新页面,看板状态是否正确持久化。
- 部署上线:将项目推送到 GitHub 仓库,连接 Vercel 自动部署。在 Vercel Dashboard 中添加所有环境变量(DATABASE_URL、NEXTAUTH_SECRET、GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET)。部署完成后创建一个测试看板,添加任务并测试拖拽功能,确认所有功能在生产环境中正常运行。