引言:为什么选择Next.js构建旅游预订网站?
在当今的Web开发领域,Next.js已经成为构建高性能、SEO友好的React应用的首选框架。对于旅游预订网站这样的复杂应用,Next.js提供了许多独特的优势。
旅游预订网站通常具有以下特点:
SEO要求高
:需要被搜索引擎良好索引,吸引自然流量
数据密集
:涉及大量的旅游产品、目的地、价格等数据
用户体验要求高
:需要快速加载、流畅交互
多用户角色
:游客、注册用户、管理员等
Next.js通过其混合渲染模式(SSR + SSG)完美解决了这些需求。例如,首页和目的地页面可以使用SSG预渲染,而用户个人中心则使用SSR动态渲染。
1. 项目初始化与技术栈选择
1.1 创建Next.js项目
首先,我们需要创建一个基于TypeScript的Next.js项目:
# 使用create-next-app创建项目
npx create-next-app@latest travel-booking-app –typescript –tailwind –eslint –app
# 进入项目目录
cd travel-booking-app
# 安装必要的依赖
npm install react-icons react-hot-toast zustand
npm install -D @types/react-datepicker react-datepicker
1.2 项目结构规划
一个典型的旅游预订网站应该具有以下目录结构:
travel-booking-app/
├── app/
│ ├── (main)/
│ │ ├── page.tsx # 首页
│ │ ├── destinations/ # 目的地页面
│ │ ├── tours/ # 旅游产品页面
│ │ └── about/
│ ├── (auth)/
│ │ ├── login/page.tsx
│ │ └── register/page.tsx
│ ├── (dashboard)/
│ │ ├── bookings/page.tsx # 用户预订管理
│ │ └── profile/page.tsx
│ ├── api/
│ │ ├── auth/
│ │ ├── bookings/
│ │ └── tours/
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/ # 可复用UI组件
│ ├── layout/ # 布局组件
│ └── tour/ # 旅游相关组件
├── lib/
│ ├── database.ts # 数据库连接
│ └── utils.ts # 工具函数
├── types/
│ └── index.ts # TypeScript类型定义
├── public/ # 静态资源
└── styles/ # 全局样式
1.3 配置Tailwind CSS
在
tailwind.config.ts
中添加旅游网站的自定义主题:
import type { Config } from “tailwindcss”;
const config: Config = {
content: [
“./pages/**/*.{js,ts,jsx,tsx,mdx}”,
“./components/**/*.{js,ts,jsx,tsx,mdx}”,
“./app/**/*.{js,ts,jsx,tsx,mdx}”,
],
theme: {
extend: {
colors: {
primary: {
50: “#eff6ff”,
100: “#dbeafe”,
500: “#3b82f6”,
600: “#2563eb”,
700: “#1d4ed8”,
},
secondary: {
500: “#10b981”,
600: “#059669”,
},
},
fontFamily: {
sans: [“Inter”, “system-ui”, “sans-serif”],
},
},
},
plugins: [],
};
export default config;
2. 数据模型与TypeScript类型定义
2.1 核心数据类型
在
types/index.ts
中定义旅游网站的核心数据结构:
// 用户类型
export interface User {
id: string;
email: string;
name: string;
role: “user” | “admin”;
createdAt: Date;
updatedAt: Date;
}
// 旅游产品类型
export interface Tour {
id: string;
title: string;
description: string;
destination: string;
price: number;
duration: number; // 天数
images: string[];
highlights: string[];
itinerary: ItineraryItem[];
availableDates: Date[];
maxGroupSize: number;
difficulty: “easy” | “medium” | “hard”;
rating: number;
reviewsCount: number;
}
export interface ItineraryItem {
day: number;
title: string;
description: string;
activities: string[];
}
// 预订类型
export interface Booking {
id: string;
userId: string;
tourId: string;
tour: Tour; // 关联的旅游产品
date: Date;
participants: number;
totalPrice: number;
status: “pending” | “confirmed” | “cancelled”;
specialRequests?: string;
createdAt: Date;
}
// 搜索参数类型
export interface SearchParams {
destination?: string;
date?: Date;
minPrice?: number;
maxPrice?: number;
difficulty?: string;
duration?: number;
}
3. 数据库集成(使用Prisma + PostgreSQL)
3.1 安装和配置Prisma
# 安装Prisma
npm install -D prisma
npm install @prisma/client
# 初始化Prisma
npx prisma init
在
.env
文件中配置数据库连接:
DATABASE_URL=”postgresql://username:password@localhost:5432/travel_booking?schema=public”
JWT_SECRET=”your-super-secret-jwt-key”
3.2 Prisma Schema定义
在
prisma/schema.prisma
中定义数据模型:
// prisma/schema.prisma
generator client {
provider = “prisma-client-js”
}
datasource db {
provider = “postgresql”
url = env(“DATABASE_URL”)
}
model User {
id String @id @default(uuid())
email String @unique
name String
password String
role Role @default(USER)
bookings Booking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(“users”)
}
model Tour {
id String @id @default(uuid())
title String
description String
destination String
price Float
duration Int
images String[]
highlights String[]
itinerary Json // 存储行程安排
availableDates DateTime[]
maxGroupSize Int
difficulty String
rating Float @default(0)
reviewsCount Int @default(0)
bookings Booking[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(“tours”)
}
model Booking {
id String @id @default(uuid())
userId String
tourId String
date DateTime
participants Int
totalPrice Float
status BookingStatus @default(PENDING)
specialRequests String?
user User @relation(fields: [userId], references: [id])
tour Tour @relation(fields: [tourId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map(“bookings”)
}
enum Role {
USER
ADMIN
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
}
3.3 生成Prisma客户端和数据库迁移
# 生成Prisma客户端
npx prisma generate
# 创建数据库迁移
npx prisma migrate dev –name init
# 可选:填充测试数据
npx prisma db seed
3.4 创建数据库连接工具
在
lib/database.ts
中:
import { PrismaClient } from ‘@prisma/client’
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== ‘production’) globalForPrisma.prisma = prisma
4. 认证系统实现(JWT + NextAuth)
4.1 安装NextAuth
npm install next-auth @next-auth/prisma-adapter
4.2 配置NextAuth
创建
app/api/auth/[…nextauth]/route.ts
:
import NextAuth from “next-auth”
import CredentialsProvider from “next-auth/providers/credentials”
import { PrismaAdapter } from “@next-auth/prisma-adapter”
import { prisma } from “@/lib/database”
import bcrypt from “bcryptjs”
const handler = NextAuth({
adapter: PrismaAdapter(prisma),
providers: [
CredentialsProvider({
name: “credentials”,
credentials: {
email: { label: “Email”, type: “email” },
password: { label: “Password”, type: “password” }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
throw new Error(“Email and password required”)
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string }
})
if (!user || !user.password) {
throw new Error(“User not found”)
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.password
)
if (!isValid) {
throw new Error(“Invalid password”)
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
}
}
})
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role
token.id = user.id
}
return token
},
async session({ session, token }) {
if (session.user) {
session.user.role = token.role
session.user.id = token.id as string
}
return session
}
},
pages: {
signIn: “/login”,
},
session: {
strategy: “jwt”,
},
secret: process.env.JWT_SECRET,
})
export { handler as GET, handler as POST }
4.3 创建注册API
创建
app/api/auth/register/route.ts
:
import { NextRequest, NextResponse } from “next/server”
import bcrypt from “bcryptjs”
import { prisma } from “@/lib/database”
export async function POST(request: NextRequest) {
try {
const { email, name, password } = await request.json()
// 验证输入
if (!email || !name || !password) {
return NextResponse.json(
{ error: “Missing required fields” },
{ status: 400 }
)
}
// 检查用户是否已存在
const existingUser = await prisma.user.findUnique({
where: { email }
})
if (existingUser) {
return NextResponse.json(
{ error: “User already exists” },
{ status: 400 }
)
}
// 哈希密码
const hashedPassword = await bcrypt.hash(password, 12)
// 创建用户
const user = await prisma.user.create({
data: {
email,
name,
password: hashedPassword,
role: “USER”
}
})
// 返回用户信息(不包含密码)
const { password: _, …userWithoutPassword } = user
return NextResponse.json(
{ message: “User created successfully”, user: userWithoutPassword },
{ status: 201 }
)
} catch (error) {
console.error(“Registration error:”, error)
return NextResponse.json(
{ error: “Internal server error” },
{ status: 500 }
)
}
}
5. 核心页面开发
5.1 主页(SSG预渲染)
创建
app/(main)/page.tsx
:
import { prisma } from “@/lib/database”
import { Tour } from “@/types”
import Image from “next/image”
import Link from “next/link”
import { FaStar, FaClock, FaUsers } from “react-icons/fa”
// 使用SSG预渲染首页数据
export async function generateStaticParams() {
// 预取热门旅游产品
const tours = await prisma.tour.findMany({
take: 6,
orderBy: { rating: “desc” },
select: {
id: true,
title: true,
destination: true,
price: true,
images: true,
rating: true,
duration: true,
}
})
return tours.map(tour => ({ id: tour.id }))
}
export default async function HomePage() {
// 获取热门目的地和推荐旅游
const [featuredTours, topDestinations] = await Promise.all([
prisma.tour.findMany({
take: 6,
orderBy: { rating: “desc” },
include: {
_count: {
select: { bookings: true }
}
}
}),
prisma.tour.findMany({
take: 4,
distinct: [“destination”],
select: {
destination: true,
images: true,
}
})
])
return (
Discover Your Next Adventure
Explore the world’s most amazing destinations with curated travel experiences
{/* Featured Tours */}
Featured Tours
))}
{/* Top Destinations */}
Top Destinations
{dest.destination}
))}
)
}
// Tour Card Component
function TourCard({ tour }: { tour: any }) {
return (
{tour.rating}
({tour._count.bookings} bookings)
{tour.title}
{tour.destination}
)
}
5.2 旅游产品详情页(SSR动态渲染)
创建
app/(main)/tours/[id]/page.tsx
:
import { prisma } from “@/lib/database”
import { notFound } from “next/navigation”
import Image from “next/image”
import { FaStar, FaClock, FaUsers, FaCalendar } from “react-icons/fa”
import { getServerSession } from “next-auth”
import { authOptions } from “@/app/api/auth/[…nextauth]/route”
import BookingForm from “@/components/BookingForm”
interface PageProps {
params: {
id: string
}
}
export default async function TourDetailPage({ params }: PageProps) {
const session = await getServerSession(authOptions)
const tour = await prisma.tour.findUnique({
where: { id: params.id },
include: {
_count: {
select: { bookings: true }
}
}
})
if (!tour) {
notFound()
}
// 转换JSON字段
const itinerary = tour.itinerary as any[]
return (
))}
{tour.title}
{tour.rating} ({tour._count.bookings} reviews)
{tour.description}
{/* Highlights */}
Tour Highlights
-
{tour.highlights.map((highlight, index) => (
-
✓
{highlight}
))}
{/* Itinerary */}
Itinerary
Day {item.day}: {item.title}
{item.description}
-
{item.activities.map((activity: string, i: number) => (
-
{activity}
))}
))}
{/* Sidebar – Booking */}
/ person
{tour.availableDates.length} dates
{tour.difficulty}
)
}
5.3 预订表单组件
创建
components/BookingForm.tsx
:
“use client”
import { useState } from “react”
import { useRouter } from “next/navigation”
import { toast } from “react-hot-toast”
import { FaCalendar, FaUsers } from “react-icons/fa”
interface BookingFormProps {
tour: {
id: string
price: number
maxGroupSize: number
availableDates: Date[]
}
user?: {
id: string
email: string
name: string
}
}
export default function BookingForm({ tour, user }: BookingFormProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [formData, setFormData] = useState({
date: “”,
participants: 1,
specialRequests: “”
})
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const response = await fetch(“/api/bookings”, {
method: “POST”,
headers: { “Content-Type”: “application/json” },
body: JSON.stringify({
tourId: tour.id,
date: formData.date,
participants: parseInt(formData.participants.toString()),
specialRequests: formData.specialRequests,
userId: user?.id
})
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || “Failed to create booking”)
}
toast.success(“Booking created successfully!”)
router.push(“/dashboard/bookings”)
router.refresh()
} catch (error) {
toast.error(error instanceof Error ? error.message : “Booking failed”)
} finally {
setLoading(false)
}
}
const totalPrice = tour.price * formData.participants
if (!user) {
return (
Please sign in to book this tour
)
}
return (
)
}
5.4 用户仪表盘(受保护页面)
创建
app/(dashboard)/dashboard/bookings/page.tsx
:
import { getServerSession } from “next-auth”
import { authOptions } from “@/app/api/auth/[…nextauth]/route”
import { redirect } from “next/navigation”
import { prisma } from “@/lib/database”
import { FaCalendar, FaMapMarkerAlt, FaUsers } from “react-icons/fa”
import Link from “next/link”
import CancelBookingButton from “@/components/CancelBookingButton”
export default async function DashboardBookingsPage() {
const session = await getServerSession(authOptions)
if (!session || !session.user?.email) {
redirect(“/login”)
}
// 获取用户的预订
const bookings = await prisma.booking.findMany({
where: {
user: {
email: session.user.email
}
},
include: {
tour: {
select: {
title: true,
destination: true,
images: true,
duration: true
}
}
},
orderBy: {
createdAt: “desc”
}
})
return (
My Bookings
Browse Tours
{bookings.length === 0 ? (
You haven’t booked any tours yet
Explore Tours
) : (
{booking.tour.title}
{booking.tour.destination}
{new Date(booking.date).toLocaleDateString()}
{booking.participants} participants
${booking.totalPrice}
{booking.status}
Notes: {booking.specialRequests}
)}
{booking.status === “PENDING” && (
)}
))}
)}
)
}
5.5 取消预订按钮组件
创建
components/CancelBookingButton.tsx
:
“use client”
import { useState } from “react”
import { useRouter } from “next/navigation”
import { toast } from “react-hot-toast”
interface CancelBookingButtonProps {
bookingId: string
}
export default function CancelBookingButton({ bookingId }: CancelBookingButtonProps) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const handleCancel = async () => {
if (!confirm(“Are you sure you want to cancel this booking?”)) {
return
}
setLoading(true)
try {
const response = await fetch(`/api/bookings/${bookingId}`, {
method: “PATCH”,
headers: { “Content-Type”: “application/json” },
body: JSON.stringify({ status: “CANCELLED” })
})
if (!response.ok) {
throw new Error(“Failed to cancel booking”)
}
toast.success(“Booking cancelled successfully”)
router.refresh()
} catch (error) {
toast.error(“Failed to cancel booking”)
} finally {
setLoading(false)
}
}
return (
)
}
6. API路由开发
6.1 预订API
创建
app/api/bookings/route.ts
:
import { NextRequest, NextResponse } from “next/server”
import { getServerSession } from “next-auth”
import { authOptions } from “../auth/[…nextauth]/route”
import { prisma } from “@/lib/database”
import { z } from “zod”
// 验证schema
const bookingSchema = z.object({
tourId: z.string().uuid(),
date: z.string().datetime(),
participants: z.number().int().min(1),
specialRequests: z.string().optional(),
userId: z.string().optional()
})
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.email) {
return NextResponse.json(
{ error: “Unauthorized” },
{ status: 401 }
)
}
const body = await request.json()
const validatedData = bookingSchema.parse(body)
// 获取用户信息
const user = await prisma.user.findUnique({
where: { email: session.user.email }
})
if (!user) {
return NextResponse.json(
{ error: “User not found” },
{ status: 404 }
)
}
// 检查旅游产品是否存在且有库存
const tour = await prisma.tour.findUnique({
where: { id: validatedData.tourId },
include: {
_count: {
select: { bookings: true }
}
}
})
if (!tour) {
return NextResponse.json(
{ error: “Tour not found” },
{ status: 404 }
)
}
// 检查日期是否可用
if (!tour.availableDates.some(d =>
new Date(d).toISOString() === new Date(validatedData.date).toISOString()
)) {
return NextResponse.json(
{ error: “Selected date is not available” },
{ status: 400 }
)
}
// 检查是否已预订
const existingBooking = await prisma.booking.findFirst({
where: {
userId: user.id,
tourId: validatedData.tourId,
date: new Date(validatedData.date),
status: { not: “CANCELLED” }
}
})
if (existingBooking) {
return NextResponse.json(
{ error: “You have already booked this tour for the selected date” },
{ status: 400 }
)
}
// 计算总价
const totalPrice = tour.price * validatedData.participants
// 创建预订
const booking = await prisma.booking.create({
data: {
userId: user.id,
tourId: validatedData.tourId,
date: new Date(validatedData.date),
participants: validatedData.participants,
totalPrice,
specialRequests: validatedData.specialRequests,
status: “PENDING”
},
include: {
tour: {
select: {
title: true,
destination: true
}
}
}
})
return NextResponse.json(
{ message: “Booking created successfully”, booking },
{ status: 201 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: “Invalid data”, details: error.errors },
{ status: 400 }
)
}
console.error(“Booking error:”, error)
return NextResponse.json(
{ error: “Internal server error” },
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.email) {
return NextResponse.json(
{ error: “Unauthorized” },
{ status: 401 }
)
}
const { searchParams } = new URL(request.url)
const status = searchParams.get(“status”)
const bookings = await prisma.booking.findMany({
where: {
user: {
email: session.user.email
},
…(status && { status })
},
include: {
tour: {
select: {
title: true,
destination: true,
images: true
}
}
},
orderBy: { createdAt: “desc” }
})
return NextResponse.json({ bookings }, { status: 200 })
} catch (error) {
console.error(“Get bookings error:”, error)
return NextResponse.json(
{ error: “Internal server error” },
{ status: 500 }
)
}
}
6.2 单个预订API(用于取消)
创建
app/api/bookings/[id]/route.ts
:
import { NextRequest, NextResponse } from “next/server”
import { getServerSession } from “next-auth”
import { authOptions } from “../../auth/[…nextauth]/route”
import { prisma } from “@/lib/database”
interface Context {
params: {
id: string
}
}
export async function PATCH(request: NextRequest, { params }: Context) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.email) {
return NextResponse.json(
{ error: “Unauthorized” },
{ status: 401 }
)
}
const { status } = await request.json()
if (![“CANCELLED”, “CONFIRMED”].includes(status)) {
return NextResponse.json(
{ error: “Invalid status” },
{ status: 400 }
)
}
// 验证预订存在且属于当前用户
const booking = await prisma.booking.findFirst({
where: {
id: params.id,
user: {
email: session.user.email
}
}
})
if (!booking) {
return NextResponse.json(
{ error: “Booking not found” },
{ status: 404 }
)
}
// 只能取消自己的预订
if (status === “CANCELLED” && booking.status === “CONFIRMED”) {
return NextResponse.json(
{ error: “Confirmed bookings cannot be cancelled online. Please contact support.” },
{ status: 400 }
)
}
const updatedBooking = await prisma.booking.update({
where: { id: params.id },
data: { status },
include: {
tour: {
select: {
title: true
}
}
}
})
return NextResponse.json(
{ message: “Booking updated successfully”, booking: updatedBooking },
{ status: 200 }
)
} catch (error) {
console.error(“Update booking error:”, error)
return NextResponse.json(
{ error: “Internal server error” },
{ status: 500 }
)
}
}
6.3 旅游搜索API
创建
app/api/tours/search/route.ts
:
import { NextRequest, NextResponse } from “next/server”
import { prisma } from “@/lib/database”
import { z } from “zod”
const searchSchema = z.object({
destination: z.string().optional(),
minPrice: z.number().optional(),
maxPrice: z.number().optional(),
difficulty: z.enum([“easy”, “medium”, “hard”]).optional(),
duration: z.number().optional(),
page: z.number().int().min(1).default(1),
limit: z.number().int().min(1).max(50).default(12)
})
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const params = {
destination: searchParams.get(“destination”) || undefined,
minPrice: searchParams.get(“minPrice”) ? Number(searchParams.get(“minPrice”)) : undefined,
maxPrice: searchParams.get(“maxPrice”) ? Number(searchParams.get(“maxPrice”)) : undefined,
difficulty: searchParams.get(“difficulty”) || undefined,
duration: searchParams.get(“duration”) ? Number(searchParams.get(“duration”)) : undefined,
page: searchParams.get(“page”) ? Number(searchParams.get(“page”)) : 1,
limit: searchParams.get(“limit”) ? Number(searchParams.get(“limit”)) : 12
}
const validatedParams = searchSchema.parse(params)
// 构建查询条件
const where: any = {
AND: []
}
if (validatedParams.destination) {
where.AND.push({
destination: {
contains: validatedParams.destination,
mode: “insensitive”
}
})
}
if (validatedParams.minPrice !== undefined) {
where.AND.push({
price: {
gte: validatedParams.minPrice
}
})
}
if (validatedParams.maxPrice !== undefined) {
where.AND.push({
price: {
lte: validatedParams.maxPrice
}
})
}
if (validatedParams.difficulty) {
where.AND.push({
difficulty: validatedParams.difficulty
})
}
if (validatedParams.duration) {
where.AND.push({
duration: validatedParams.duration
})
}
// 如果没有条件,返回空AND数组
if (where.AND.length === 0) {
delete where.AND
}
const skip = (validatedParams.page – 1) * validatedParams.limit
const [tours, totalCount] = await Promise.all([
prisma.tour.findMany({
where,
skip,
take: validatedParams.limit,
orderBy: { rating: “desc” },
include: {
_count: {
select: { bookings: true }
}
}
}),
prisma.tour.count({ where })
])
const totalPages = Math.ceil(totalCount / validatedParams.limit)
return NextResponse.json(
{
tours,
pagination: {
page: validatedParams.page,
limit: validatedParams.limit,
totalCount,
totalPages,
hasNext: validatedParams.page < totalPages,
hasPrev: validatedParams.page > 1
}
},
{ status: 200 }
)
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: “Invalid search parameters”, details: error.errors },
{ status: 400 }
)
}
console.error(“Search error:”, error)
return NextResponse.json(
{ error: “Internal server error” },
{ status: 500 }
)
}
}
7. 搜索与筛选功能
7.1 搜索页面
创建
app/(main)/tours/page.tsx
:
import { prisma } from “@/lib/database”
import Image from “next/image”
import Link from “next/link”
import { FaSearch, FaFilter, FaStar, FaClock, FaUsers } from “react-icons/fa”
import SearchFilters from “@/components/SearchFilters”
interface SearchParams {
destination?: string
minPrice?: string
maxPrice?: string
difficulty?: string
duration?: string
page?: string
}
export default async function ToursPage({ searchParams }: { searchParams: SearchParams }) {
// 构建查询条件
const where: any = { AND: [] }
if (searchParams.destination) {
where.AND.push({
destination: {
contains: searchParams.destination,
mode: “insensitive”
}
})
}
if (searchParams.minPrice) {
where.AND.push({ price: { gte: Number(searchParams.minPrice) } })
}
if (searchParams.maxPrice) {
where.AND.push({ price: { lte: Number(searchParams.maxPrice) } })
}
if (searchParams.difficulty) {
where.AND.push({ difficulty: searchParams.difficulty })
}
if (searchParams.duration) {
where.AND.push({ duration: Number(searchParams.duration) })
}
if (where.AND.length === 0) {
delete where.AND
}
const page = Number(searchParams.page) || 1
const limit = 12
const skip = (page – 1) * limit
const [tours, totalCount] = await Promise.all([
prisma.tour.findMany({
where,
skip,
take: limit,
orderBy: { rating: “desc” },
include: {
_count: {
select: { bookings: true }
}
}
}),
prisma.tour.count({ where })
])
const totalPages = Math.ceil(totalCount / limit)
return (
Find Your Perfect Tour
{/* Results */}
Filters
{/* Results Grid */}
Found {totalCount} tours
Page {page} of {totalPages}
{tours.length === 0 ? (
No tours found matching your criteria
Clear filters
) : (
{tour.rating}
({tour._count.bookings} bookings)
{tour.title}
{tour.destination}
{tour.difficulty}
))}
)}
{/* Pagination */}
{totalPages > 1 && (
{pageNum}
))}
)}
)
}
7.2 搜索过滤器组件
创建
components/SearchFilters.tsx
:
“use client”
import { useRouter, useSearchParams } from “next/navigation”
import { FaSearch, FaMapMarkerAlt, FaCalendar, FaDollarSign } from “react-icons/fa”
import { useState, useEffect } from “react”
export default function SearchFilters() {
const router = useRouter()
const searchParams = useSearchParams()
const [filters, setFilters] = useState({
destination: searchParams.get(“destination”) || “”,
minPrice: searchParams.get(“minPrice”) || “”,
maxPrice: searchParams.get(“maxPrice”) || “”,
difficulty: searchParams.get(“difficulty”) || “”,
duration: searchParams.get(“duration”) || “”
})
const [isMounted, setIsMounted] = useState(false)
useEffect(() => {
setIsMounted(true)
}, [])
const handleInputChange = (e: React.ChangeEvent
const { name, value } = e.target
setFilters(prev => ({ …prev, [name]: value }))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const params = new URLSearchParams()
Object.entries(filters).forEach(([key, value]) => {
if (value) {
params.set(key, value.toString())
}
})
router.push(`/tours?${params.toString()}`)
}
const handleClear = () => {
setFilters({
destination: “”,
minPrice: “”,
maxPrice: “”,
difficulty: “”,
duration: “”
})
router.push(“/tours”)
}
if (!isMounted) {
return null // 避免hydration错误
}
return (
)
}
8. 管理员功能
8.1 管理员中间件
创建
middleware.ts
在项目根目录:
import { withAuth } from “next-auth/middleware”
import { NextResponse } from “next/server”
export default withAuth(
function middleware(req) {
const path = req.nextUrl.pathname
// 保护管理员路由
if (path.startsWith(“/admin”)) {
const token = req.nextauth.token
if (!token || token.role !== “admin”) {
return NextResponse.redirect(new URL(“/dashboard”, req.url))
}
}
return NextResponse.next()
},
{
callbacks: {
authorized: ({ token }) => {
// 允许通过中间件,具体路由的保护在页面内处理
return true
}
}
}
)
export const config = {
matcher: [“/admin/:path*”, “/dashboard/:path*”]
}
8.2 管理员仪表盘页面
创建
app/(admin)/admin/page.tsx
:
import { getServerSession } from “next-auth”
import { authOptions } from “@/app/api/auth/[…nextauth]/route”
import { redirect } from “next/navigation”
import { prisma } from “@/lib/database”
import { FaUsers, FaClipboardList, FaMountain, FaChartLine } from “react-icons/fa”
export default async function AdminDashboard() {
const session = await getServerSession(authOptions)
if (!session?.user?.email || session.user.role !== “admin”) {
redirect(“/dashboard”)
}
const [usersCount, bookingsCount, toursCount, recentBookings] = await Promise.all([
prisma.user.count(),
prisma.booking.count(),
prisma.tour.count(),
prisma.booking.findMany({
take: 10,
orderBy: { createdAt: “desc” },
include: {
user: { select: { name: true, email: true } },
tour: { select: { title: true } }
}
})
])
const stats = [
{ name: “Total Users”, value: usersCount, icon: FaUsers, color: “bg-blue-500” },
{ name: “Total Bookings”, value: bookingsCount, icon: FaClipboardList, color: “bg-green-500” },
{ name: “Total Tours”, value: toursCount, icon: FaMountain, color: “bg-purple-500” },
{ name: “Revenue”, value: “$12,450”, icon: FaChartLine, color: “bg-orange-500” },
]
return (
Admin Dashboard
{/* Stats Grid */}
const Icon = stat.icon
return (
{stat.name}
{stat.value}
)
})}
{/* Recent Bookings */}
Recent Bookings
| User | Tour | Date | Participants | Total | Status |
|---|---|---|---|---|---|
|
{booking.user.name}
{booking.user.email}
|
{booking.tour.title} | {new Date(booking.date).toLocaleDateString()} | {booking.participants} | ${booking.totalPrice} |
{booking.status} |
{/* Quick Actions */}
)
}
9. 部署指南
9.1 环境变量配置
在
.env.production
中配置生产环境变量:
# Database
DATABASE_URL=”postgresql://user:password@host:5432/dbname?sslmode=require”
# Authentication
JWT_SECRET=”your-production-jwt-secret-key”
# NextAuth
NEXTAUTH_URL=”https://yourdomain.com”
NEXTAUTH_SECRET=”your-nextauth-secret”
# Cloudinary (for image uploads)
CLOUDINARY_CLOUD_NAME=”your-cloud-name”
CLOUDINARY_API_KEY=”your-api-key”
CLOUDINARY_API_SECRET=”your-api-secret”
9.2 准备生产部署
9.2.1 构建优化
在
next.config.js
中添加生产优化配置:
/** @type {import(‘next’).NextConfig} */
const nextConfig = {
// 图像优化配置
images: {
domains: [‘res.cloudinary.com’], // 添加你的图片域名
formats: [‘image/avif’, ‘image/webp’],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
// 编译优化
compiler: {
removeConsole: process.env.NODE_ENV === ‘production’,
},
// 缓存配置
async headers() {
return [
{
source: ‘/(.*)’,
headers: [
{
key: ‘X-Content-Type-Options’,
value: ‘nosniff’,
},
{
key: ‘X-Frame-Options’,
value: ‘DENY’,
},
{
key: ‘X-XSS-Protection’,
value: ‘1; mode=block’,
},
{
key: ‘Referrer-Policy’,
value: ‘origin-when-cross-origin’,
},
],
},
]
},
// SWC 编译
swcMinify: true,
// 实验性功能
experimental: {
optimizePackageImports: [‘lucide-react’, ‘react-icons’],
},
}
module.exports = nextConfig
9.2.2 添加生产依赖
# 安装生产依赖
npm install bcryptjs
npm install -D @types/bcryptjs
# 如果使用PostgreSQL生产数据库
npm install pg
npm install -D @types/pg
# 如果使用Cloudinary进行图片存储
npm install cloudinary
9.3 部署到Vercel(推荐)
9.3.1 准备Vercel项目
安装Vercel CLI
:
npm i -g vercel
登录Vercel
:
vercel login
准备部署
:
# 构建项目
npm run build
# 本地测试生产构建
npm start
9.3.2 部署步骤
方法一:使用Vercel CLI
# 在项目根目录运行
vercel
# 首次运行会提示配置项目
# 选择 Link to existing project 或 Create new project
# 确认项目设置
# 部署生产环境
vercel –prod
方法二:使用Vercel网站
推送代码到GitHub/GitLab
登录Vercel仪表板
点击”Add New…” → “Project”
导入你的代码仓库
配置环境变量
点击”Deploy”
9.3.3 配置Vercel环境变量
在Vercel仪表板中,导航到Project Settings → Environment Variables:
# 必需的环境变量
DATABASE_URL=postgresql://…
JWT_SECRET=…
NEXTAUTH_URL=https://your-app.vercel.app
NEXTAUTH_SECRET=…
9.3.4 配置Vercel Postgres(可选)
如果你不想使用外部PostgreSQL数据库,可以使用Vercel Postgres:
# 安装Vercel Postgres
npm install @vercel/postgres
# 初始化
npx vercel env pull .env.local
更新
lib/database.ts
:
import { PrismaClient } from ‘@prisma/client’
import { createClient } from ‘@vercel/postgres’
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
// 使用Vercel Postgres的连接字符串
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== ‘production’) globalForPrisma.prisma = prisma
9.4 部署到其他平台
9.4.1 部署到Railway(适合全栈部署)
# 1. 安装Railway CLI
npm i -g @railway/cli
# 2. 登录
railway login
# 3. 创建新项目
railway init
# 4. 配置环境变量
railway variables set DATABASE_URL=…
railway variables set JWT_SECRET=…
# 5. 部署
railway up
9.4.2 部署到Heroku
# 1. 安装Heroku CLI
npm i -g heroku
# 2. 登录
heroku login
# 3. 创建应用
heroku create your-app-name
# 4. 添加PostgreSQL插件
heroku addons:create heroku-postgresql:hobby-dev
# 5. 配置环境变量
heroku config:set JWT_SECRET=…
heroku config:set NEXTAUTH_URL=https://your-app.herokuapp.com
# 6. 部署
git push heroku main
9.5 生产环境优化
9.5.1 数据库连接池配置
在生产环境中,配置适当的连接池大小:
// lib/database.ts
import { PrismaClient } from ‘@prisma/client’
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL,
},
},
// 生产环境连接池配置
…(process.env.NODE_ENV === ‘production’ && {
prisma: {
adapter: {
connectionLimit: 10,
},
},
}),
})
export default prisma
9.5.2 缓存策略
在
next.config.js
中配置缓存:
async headers() {
return [
{
source: ‘/_next/static/(.*)’,
headers: [
{
key: ‘Cache-Control’,
value: ‘public, max-age=31536000, immutable’,
},
],
},
{
source: ‘/api/(.*)’,
headers: [
{
key: ‘Cache-Control’,
value: ‘no-store, max-age=0’,
},
],
},
]
}
9.5.3 监控和日志
安装监控工具:
# Sentry for error tracking
npm install @sentry/nextjs
# Vercel Analytics
npm install @vercel/analytics
配置Sentry(
sentry.server.config.js
):
import * as Sentry from “@sentry/nextjs”
Sentry.init({
dsn: process.env.SENTRY_DSN,
tracesSampleRate: 1.0,
environment: process.env.NODE_ENV,
})
9.6 域名配置
9.6.1 Vercel域名配置
在Vercel项目设置中,点击”Domains”
添加你的自定义域名(如:yourdomain.com)
按照提示配置DNS记录
等待DNS传播(通常几分钟到几小时)
9.6.2 SSL证书
Vercel会自动为你的域名配置SSL证书,无需手动操作。
9.7 部署后检查清单
部署完成后,确保检查以下事项:
[ ] 所有环境变量已正确配置
[ ] 数据库迁移已应用
[ ] 图片上传功能正常(如果使用Cloudinary)
[ ] 认证系统工作正常
[ ] 预订流程完整
[ ] 邮件通知功能(如果实现)
[ ] 移动端响应式设计
[ ] 页面加载速度优化
[ ] SEO元标签正确设置
[ ] 错误边界处理
[ ] 支付集成(如果实现)
9.8 故障排除
9.8.1 常见部署问题
问题1:数据库连接失败
# 检查DATABASE_URL格式
# PostgreSQL格式:postgresql://USER:PASSWORD@HOST:PORT/DATABASE?sslmode=require
# 如果使用Vercel Postgres,确保在Vercel仪表板中正确配置
问题2:构建失败
# 检查Node.js版本(需要18.17+)
node –version
# 清理缓存并重新安装
rm -rf node_modules package-lock.json
npm install
npm run build
问题3:认证失败
# 确保NEXTAUTH_URL和NEXTAUTH_SECRET已设置
# 检查JWT_SECRET是否与开发环境一致
9.8.2 性能监控
使用Vercel Analytics监控性能:
// app/layout.tsx
import { Analytics } from ‘@vercel/analytics/react’
export default function RootLayout({ children }) {
return (
{children}
)
}
10. 总结
通过本教程,你已经学习了如何使用Next.js构建一个完整的旅游预订网站。我们涵盖了:
核心技术栈
Next.js 14
:App Router + Server Components
TypeScript
:类型安全
Tailwind CSS
:样式系统
Prisma
:ORM + PostgreSQL
NextAuth
:认证系统
Zod
:数据验证
主要功能
✅ 用户注册和登录
✅ 旅游产品展示(SSG + SSR)
✅ 搜索和筛选
✅ 预订流程
✅ 用户仪表盘
✅ 管理员功能
✅ API路由保护
部署要点
环境变量配置
数据库连接管理
生产优化
安全最佳实践
下一步建议
添加支付集成
:集成Stripe或PayPal
邮件通知
:使用SendGrid或Resend发送预订确认邮件
图片上传
:实现管理员上传旅游图片功能
评论系统
:允许用户对旅游产品进行评价
实时通知
:使用WebSocket或Server-Sent Events
国际化
:支持多语言
缓存策略
:实现Redis缓存提高性能
测试
:添加单元测试和E2E测试
这个项目为你提供了一个坚实的基础,可以根据具体需求进行扩展和定制。祝你的旅游预订网站开发顺利!