实现课程展示页面 | 自在学实现课程展示页面
课程展示页面是教育网站的核心功能之一。在这一堂课中,我们将学习如何创建课程列表页面,设计课程卡片组件,使用 Next.js Image 组件优化图片,并实现筛选和搜索功能。

创建 CourseCard 组件
在开始构建课程展示页面之前,我们需要先创建一个可复用的课程卡片组件。这个组件将用于展示单个课程的信息,包括课程图片、标题、描述、类别和难度等信息。
首先,在 components 文件夹下创建 CourseCard.tsx 文件:
import Image from 'next/image'
import Link from 'next/link'
interface Course {
id: number
title: string
description: string
category: string
level: string
image: string
}
interface CourseCardProps {
course: Course
}
export default function CourseCard({ course }: CourseCardProps) {
return (
<Link href={`/courses/${course.id}`} className="block">
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow duration-300">
{/* 课程图片 */}
<div className="relative w-full h-48">
<Image
src={course.image}
alt={course.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
</div>
{/* 课程信息 */}
<div className="p-6">
<div className="flex items-center justify-between mb-2">
<span className="px-2 py-1 text-xs font-semibold text-blue-600 bg-blue-100 rounded">
{course.category}
</span>
<span className="px-2 py-1 text-xs font-semibold text-green-600 bg-green-100 rounded">
{course.level}
</span>
</div>
<h3 className="text-xl font-bold text-gray-900 mb-2 line-clamp-2">
{course.title}
</h3>
<p className="text-gray-600 text-sm line-clamp-3">
{course.description}
</p>
</div>
</div>
</Link>
)
}
步骤 1:创建基础页面结构和课程数据
在正式开发课程展示页面之前,我们首先需要搭建页面的基本结构,并定义一组用于展示的课程示例数据。
注意:如果你还没有在 public/images/courses/ 文件夹下准备课程图片,请先创建相应的文件夹并添加图片文件。
'use client'
import { useState } from 'react'
import CourseCard from '@/components/CourseCard'
// 课程数据
const allCourses = [
{
id: 1,
title: 'React 基础教程',
description: '学习 React 的核心概念和基础用法',
category: '编程',
level: '初级',
image: '/images/courses/react.jpg'
},
{
id:
步骤 2:添加搜索功能
接下来,我们将为课程列表添加一个专业的搜索功能。该功能支持用户根据课程标题或描述关键字进行实时检索,便于快速定位感兴趣的内容。
'use client'
import { useState } from 'react'
import CourseCard from '@/components/CourseCard'
const allCourses = [
// ... 课程数据(同上)
]
export default function Courses() {
const [searchQuery, setSearchQuery] = useState('')
// 根据搜索关键词过滤课程
const filteredCourses = allCourses.
步骤 3:添加筛选功能
为了让用户能够更方便、精准地查找感兴趣的课程,我们将在搜索基础上,进一步实现“类别”和“难度”的筛选功能。这些筛选条件简单直观,能够帮助大家快速定位到最符合自身需求的课程。
'use client'
import { useState } from 'react'
import CourseCard from '@/components/CourseCard'
const allCourses = [
// ... 课程数据(同上)
]
const categories = ['全部', '编程', '数据科学', '设计', '商业']
const levels = ['全部', '初级', '中级',
上面的代码实现了一个带有搜索和筛选功能的课程列表页面。它包含如下主要逻辑:
-
使用 useState 管理状态:分别用 searchQuery、selectedCategory 和 selectedLevel 保存搜索关键字和当前筛选条件(类别与难度)。
-
课程数据与筛选项:有一个全部课程数组 allCourses,并定义了可选的类别(如“编程”、“设计”)和难度(如“初级”、“高级”)筛选项。
-
综合过滤逻辑:
- 首先判断课程标题或描述是否包含搜索关键字(不区分大小写)。
- 其次,比对当前类别筛选项(“全部”时不过滤,否则只显示匹配类别的课程)。
- 最后,比对难度筛选项(同理,支持“全部”)。
只有当以上三项都匹配时,课程才会显示在页面上。
步骤 4:添加分页功能
虽然在我们的示例中,课程数量可能还不算多,但在真实生产环境中,课程库往往会随着平台发展变得非常庞大。为了避免一次性加载所有课程导致页面加载缓慢、用户查找不便,我们需要实现分页功能。
分页不仅能显著提升页面性能,还能带来更专业、更友好的用户体验。因此,哪怕课程暂时不多,我们也建议从一开始就设计并实现课程的分页功能,这也是实际项目开发中的常见标准做法。
'use client'
import { useState, useEffect } from 'react'
import CourseCard from '@/components/CourseCard'
const allCourses = [
// ... 课程数据(同上)
]
const categories = ['全部', '编程', '数据科学', '设计', '商业']
const levels = ['全部', '初级', '中级',
完整的课程展示
下面,我们来详细看看完整的课程列表代码。这份代码已经把“搜索”、“分类/难度筛选”、“分页”等核心功能都结合到了一起,你现在可以将下面的内容复制到你的app/courses/page.tsx文件中,并运行你的项目,看看效果如何:
'use client'
import { useState, useEffect } from 'react'
import CourseCard from '@/components/CourseCard'
const allCourses = [
{
id: 1,
title: 'React 基础教程',
description: '学习 React 的核心概念和基础用法',
category: '编程',
level: '初级',
image: '/images/courses/react.jpg'
},
{
id: 2,
下一步
在这一堂课中,我们学习了如何创建课程展示页面,包括列表渲染、图片优化、筛选、搜索和分页功能。我们有一个功能比较完全的网站了,但是这些可能还不够。当我们的用户
在浏览我们的课程时出现了问题时怎么办?我们怎么解决这个问题?所以这个时候我们就需要一个联系我们的页面了,这个页面将允许用户通过表单提交问题。
2
,
title: 'Python 数据分析',
description: '使用 Python 进行数据分析和可视化',
category: '数据科学',
level: '中级',
image: '/images/courses/python.jpg'
},
{
id: 3,
title: 'UI/UX 设计入门',
description: '学习现代 UI/UX 设计原则和实践',
category: '设计',
level: '初级',
image: '/images/courses/design.jpg'
},
{
id: 4,
title: 'Next.js 全栈开发',
description: '使用 Next.js 构建全栈应用',
category: '编程',
level: '高级',
image: '/images/courses/nextjs.jpg'
},
{
id: 5,
title: '商业策略分析',
description: '学习商业分析和策略制定',
category: '商业',
level: '中级',
image: '/images/courses/business.jpg'
},
// ... 可以添加更多课程
]
export default function Courses() {
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">所有课程</h1>
<p className="text-lg text-gray-600">探索我们的免费课程库</p>
</div>
{/* 课程列表 */}
<div className="grid md:grid-cols-3 gap-6">
{allCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
</div>
</div>
)
}
filter
((
course
)
=>
{
const matchTitle = course.title.toLowerCase().includes(searchQuery.toLowerCase())
const matchDescription = course.description.toLowerCase().includes(searchQuery.toLowerCase())
return matchTitle || matchDescription
})
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">所有课程</h1>
<p className="text-lg text-gray-600">探索我们的免费课程库</p>
</div>
{/* 搜索框 */}
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<input
type="text"
placeholder="搜索课程标题或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* 结果统计 */}
<div className="mb-6 text-gray-600">
找到 {filteredCourses.length} 门课程
</div>
{/* 课程列表 */}
{filteredCourses.length > 0 ? (
<div className="grid md:grid-cols-3 gap-6">
{filteredCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<p className="text-gray-500 text-lg">没有找到匹配的课程</p>
<p className="text-gray-400 mt-2">尝试调整搜索关键词</p>
</div>
)}
</div>
</div>
)
}
'高级'
]
export default function Courses() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('全部')
const [selectedLevel, setSelectedLevel] = useState('全部')
// 综合搜索和筛选条件
const filteredCourses = allCourses.filter((course) => {
// 搜索匹配
const matchSearch = course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
course.description.toLowerCase().includes(searchQuery.toLowerCase())
// 类别匹配
const matchCategory = selectedCategory === '全部' || course.category === selectedCategory
// 难度匹配
const matchLevel = selectedLevel === '全部' || course.level === selectedLevel
return matchSearch && matchCategory && matchLevel
})
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">所有课程</h1>
<p className="text-lg text-gray-600">探索我们的免费课程库</p>
</div>
{/* 搜索和筛选 */}
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="mb-4">
<input
type="text"
placeholder="搜索课程标题或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">类别</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">难度</label>
<select
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{levels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</div>
</div>
{/* 结果统计 */}
<div className="mb-6 text-gray-600">
找到 {filteredCourses.length} 门课程
</div>
{/* 课程列表 */}
{filteredCourses.length > 0 ? (
<div className="grid md:grid-cols-3 gap-6">
{filteredCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<p className="text-gray-500 text-lg">没有找到匹配的课程</p>
<p className="text-gray-400 mt-2">尝试调整搜索条件或筛选器</p>
</div>
)}
</div>
</div>
)
}
'高级'
]
export default function Courses() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('全部')
const [selectedLevel, setSelectedLevel] = useState('全部')
const [currentPage, setCurrentPage] = useState(1)
const filteredCourses = allCourses.filter((course) => {
const matchSearch = course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
course.description.toLowerCase().includes(searchQuery.toLowerCase())
const matchCategory = selectedCategory === '全部' || course.category === selectedCategory
const matchLevel = selectedLevel === '全部' || course.level === selectedLevel
return matchSearch && matchCategory && matchLevel
})
// 分页配置
const COURSES_PER_PAGE = 6
const totalPages = Math.ceil(filteredCourses.length / COURSES_PER_PAGE)
const startIndex = (currentPage - 1) * COURSES_PER_PAGE
const paginatedCourses = filteredCourses.slice(startIndex, startIndex + COURSES_PER_PAGE)
// 当筛选条件改变时,重置到第一页
useEffect(() => {
setCurrentPage(1)
}, [searchQuery, selectedCategory, selectedLevel])
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">所有课程</h1>
<p className="text-lg text-gray-600">探索我们的免费课程库</p>
</div>
{/* 搜索和筛选 */}
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="mb-4">
<input
type="text"
placeholder="搜索课程标题或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">类别</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">难度</label>
<select
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{levels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</div>
</div>
{/* 结果统计 */}
<div className="mb-6 text-gray-600">
找到 {filteredCourses.length} 门课程
</div>
{/* 课程列表 */}
{paginatedCourses.length > 0 ? (
<>
<div className="grid md:grid-cols-3 gap-6 mb-8">
{paginatedCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
首页
</button>
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
上一页
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => {
// 只显示当前页附近的页码
return page === 1 || page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
})
.map((page, index, array) => {
// 添加省略号
const showEllipsis = index > 0 && page - array[index - 1] > 1
return (
<div key={page} className="flex gap-2">
{showEllipsis && <span className="px-2">...</span>}
<button
onClick={() => setCurrentPage(page)}
className={`px-4 py-2 border rounded-lg ${
currentPage === page
? 'bg-blue-500 text-white border-blue-500'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{page}
</button>
</div>
)
})}
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
下一页
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
末页
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<p className="text-gray-500 text-lg">没有找到匹配的课程</p>
<p className="text-gray-400 mt-2">尝试调整搜索条件或筛选器</p>
</div>
)}
</div>
</div>
)
}
title: 'Python 数据分析',
description: '使用 Python 进行数据分析和可视化',
category: '数据科学',
level: '中级',
image: '/images/courses/python.jpg'
},
{
id: 3,
title: 'UI/UX 设计入门',
description: '学习现代 UI/UX 设计原则和实践',
category: '设计',
level: '初级',
image: '/images/courses/design.jpg'
},
{
id: 4,
title: 'Next.js 全栈开发',
description: '使用 Next.js 构建全栈应用',
category: '编程',
level: '高级',
image: '/images/courses/nextjs.jpg'
},
{
id: 5,
title: '商业策略分析',
description: '学习商业分析和策略制定',
category: '商业',
level: '中级',
image: '/images/courses/business.jpg'
},
// ... 可以添加更多课程
]
const categories = ['全部', '编程', '数据科学', '设计', '商业']
const levels = ['全部', '初级', '中级', '高级']
export default function Courses() {
const [searchQuery, setSearchQuery] = useState('')
const [selectedCategory, setSelectedCategory] = useState('全部')
const [selectedLevel, setSelectedLevel] = useState('全部')
const [currentPage, setCurrentPage] = useState(1)
const filteredCourses = allCourses.filter((course) => {
const matchSearch = course.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
course.description.toLowerCase().includes(searchQuery.toLowerCase())
const matchCategory = selectedCategory === '全部' || course.category === selectedCategory
const matchLevel = selectedLevel === '全部' || course.level === selectedLevel
return matchSearch && matchCategory && matchLevel
})
const COURSES_PER_PAGE = 6
const totalPages = Math.ceil(filteredCourses.length / COURSES_PER_PAGE)
const startIndex = (currentPage - 1) * COURSES_PER_PAGE
const paginatedCourses = filteredCourses.slice(startIndex, startIndex + COURSES_PER_PAGE)
// 当筛选条件改变时,重置到第一页
useEffect(() => {
setCurrentPage(1)
}, [searchQuery, selectedCategory, selectedLevel])
return (
<div className="min-h-screen bg-gray-50 py-12">
<div className="container mx-auto px-4">
<div className="mb-8">
<h1 className="text-4xl font-bold text-gray-900 mb-4">所有课程</h1>
<p className="text-lg text-gray-600">探索我们的免费课程库</p>
</div>
{/* 搜索和筛选 */}
<div className="bg-white p-6 rounded-lg shadow-md mb-8">
<div className="mb-4">
<input
type="text"
placeholder="搜索课程标题或描述..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div className="flex flex-wrap gap-4">
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">类别</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-semibold text-gray-700 mb-2">难度</label>
<select
value={selectedLevel}
onChange={(e) => setSelectedLevel(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{levels.map((level) => (
<option key={level} value={level}>
{level}
</option>
))}
</select>
</div>
</div>
</div>
{/* 结果统计 */}
<div className="mb-6 text-gray-600">
找到 {filteredCourses.length} 门课程
</div>
{/* 课程列表 */}
{paginatedCourses.length > 0 ? (
<>
<div className="grid md:grid-cols-3 gap-6 mb-8">
{paginatedCourses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex justify-center gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
首页
</button>
<button
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
上一页
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(page => {
// 只显示当前页附近的页码
return page === 1 || page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1)
})
.map((page, index, array) => {
// 添加省略号
const showEllipsis = index > 0 && page - array[index - 1] > 1
return (
<div key={page} className="flex gap-2">
{showEllipsis && <span className="px-2">...</span>}
<button
onClick={() => setCurrentPage(page)}
className={`px-4 py-2 border rounded-lg ${
currentPage === page
? 'bg-blue-500 text-white border-blue-500'
: 'border-gray-300 hover:bg-gray-100'
}`}
>
{page}
</button>
</div>
)
})}
<button
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
下一页
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-4 py-2 border border-gray-300 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100"
>
末页
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-white rounded-lg">
<p className="text-gray-500 text-lg">没有找到匹配的课程</p>
<p className="text-gray-400 mt-2">尝试调整搜索条件或筛选器</p>
</div>
)}
</div>
</div>
)
}