免责声明: 本文基于个人经验分享,内容可能因时间、地区或个人情况而异。操作前请结合实际情况判断,必要时查询最新官方信息。如有疑问或建议,欢迎留言交流。
作为一个毫无编程经验的小白来说,学习 Next.js 并不是一件简单的事。但是在 Claude 的帮助下,我在三分钟的时间内容就搭建起了一个使用 Next.js 搭建的博客系统。
我也是刚刚接触 Next.js 。对于为什么选择 Next.js ,也是从网上听很多人说目前最具性价比的全栈路线是:Next.js做框架,Tailwind CSS搞样式,Shadcn UI来组件,Vercel 负责部署,Supabase搞定后端,Stripe处理支付。不知道对不对,先入门学学再说吧。
下图是在 Claude 的帮助下,快速生成的个人博客系统项目。该项目可以实现显示博客列表、写文章、删除文章、展示文章详情页等功能。项目包含前后端、文件操作、路由等核心功能,非常适合学习。



如何实现快速搭建个人博客系统
我是抱着在实践中学习的想法,来学习 Next.js 的整个开发流程。所以直接让 Claude 为我生成一个适合新手学习的项目。在提交提示词后,Claude 直接为我生成了整个项目的结构以及完整的代码。

第一步:电脑上安装 Node.js 环境
官网下载安装最新版的 Node.js :https://nodejs.org/zh-cn。

第二步:控制台创建项目
使用以下命令创建名称为 my-blog 项目。
npx create-next-app@latest my-blog
cd my-blog
npm run dev
第三步:创建项目结构
需要创建这些文件和文件夹:
my-blog/
├── app/
│ ├── globals.css
│ ├── layout.tsx
│ ├── page.tsx
│ ├── write/
│ │ └── page.tsx
│ ├── blog/
│ │ ├── page.tsx
│ │ └── [id]/
│ │ └── page.tsx
│ └── api/
│ └── posts/
│ ├── route.ts
│ └── [id]/
│ └── route.ts
└── data/
└── posts.json
第四步:创建数据文件
在 data/posts.json 文件中输入:
初始博客数据文件
{
"posts": [
{
"id": "1",
"title": "欢迎来到我的博客",
"content": "这是我的第一篇博客文章!今天开始用 Next.js 搭建我的个人博客。",
"author": "博主",
"createdAt": "2025-05-27T10:00:00.000Z",
"summary": "欢迎来到我的个人博客,这里将分享我的技术学习心得。"
}
],
"nextId": 2
}
第五步:设计全局样式
在 app/globals.css
文件中输入:
博客全局样式
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
/* 主容器 */
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
/* 导航栏样式 */
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.navbar h1 {
font-size: 1.8rem;
font-weight: 700;
}
.nav-links {
display: flex;
gap: 1.5rem;
margin-top: 0.5rem;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
transition: background-color 0.2s;
}
.nav-links a:hover {
background-color: rgba(255,255,255,0.2);
}
/* 卡片样式 */
.card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 1.5rem;
margin-bottom: 1.5rem;
border: 1px solid #e2e8f0;
transition: transform 0.2s, box-shadow 0.2s;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1);
}
/* 按钮样式 */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
color: white;
}
.btn-danger:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(245, 101, 101, 0.4);
}
.btn-secondary {
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
}
.btn-secondary:hover {
background: #e2e8f0;
}
/* 表单样式 */
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-input {
width: 100%;
padding: 0.75rem;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-textarea {
min-height: 200px;
resize: vertical;
}
/* 文章样式 */
.article-title {
font-size: 2rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 1rem;
}
.article-meta {
color: #64748b;
font-size: 0.9rem;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #e2e8f0;
}
.article-content {
font-size: 1.1rem;
line-height: 1.8;
color: #374151;
}
.article-content p {
margin-bottom: 1rem;
}
/* 消息提示 */
.message {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-weight: 500;
}
.message-success {
background-color: #dcfce7;
color: #166534;
border: 1px solid #bbf7d0;
}
.message-error {
background-color: #fecaca;
color: #dc2626;
border: 1px solid #fca5a5;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 2rem;
color: #64748b;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #64748b;
}
.empty-state h3 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
/* 响应式设计 */
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
.nav-links {
flex-direction: column;
gap: 0.5rem;
}
.card {
padding: 1rem;
}
.article-title {
font-size: 1.5rem;
}
}
第六步:创建布局组件
在 app/layout.tsx 文件中输入:
博客布局组件
import type { Metadata } from 'next'
import Link from 'next/link'
import './globals.css'
export const metadata: Metadata = {
title: '我的个人博客',
description: '用 Next.js 搭建的个人博客系统',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
{/* 导航栏 */}
<nav className="navbar">
<div className="container">
<h1>📝 我的个人博客</h1>
<div className="nav-links">
<Link href="/">🏠 首页</Link>
<Link href="/blog">📚 文章列表</Link>
<Link href="/write">✍️ 写文章</Link>
</div>
</div>
</nav>
{/* 主内容区域 */}
<main style={{ minHeight: 'calc(100vh - 120px)', padding: '2rem 0' }}>
<div className="container">
{children}
</div>
</main>
{/* 页脚 */}
<footer style={{
backgroundColor: '#f1f5f9',
padding: '1.5rem 0',
textAlign: 'center',
color: '#64748b',
borderTop: '1px solid #e2e8f0'
}}>
<div className="container">
<p>© 2025 我的个人博客 - 用 Next.js 构建 💙</p>
</div>
</footer>
</body>
</html>
)
}
第七步:创建首页
在 app/page.tsx
文件中输入:
博客首页
import Link from 'next/link'
export default function Home() {
return (
<div style={{ textAlign: 'center', maxWidth: '800px', margin: '0 auto' }}>
{/* 欢迎区域 */}
<section style={{ marginBottom: '3rem' }}>
<h1 style={{
fontSize: '3rem',
fontWeight: '700',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
marginBottom: '1rem'
}}>
欢迎来到我的博客 ✨
</h1>
<p style={{
fontSize: '1.2rem',
color: '#64748b',
lineHeight: '1.6',
marginBottom: '2rem'
}}>
这里是我分享技术心得、生活感悟和学习笔记的地方。
<br />
用 Next.js 搭建,简洁而美观。
</p>
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'center',
flexWrap: 'wrap'
}}>
<Link href="/blog" className="btn btn-primary">
📚 浏览文章
</Link>
<Link href="/write" className="btn btn-secondary">
✍️ 开始写作
</Link>
</div>
</section>
{/* 功能介绍 */}
<section style={{ marginBottom: '3rem' }}>
<h2 style={{
fontSize: '2rem',
marginBottom: '2rem',
color: '#1e293b'
}}>
博客功能 🚀
</h2>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '1.5rem',
textAlign: 'left'
}}>
<div className="card">
<h3 style={{
fontSize: '1.3rem',
marginBottom: '0.5rem',
color: '#4338ca'
}}>
📝 写作功能
</h3>
<p style={{ color: '#64748b' }}>
简洁的编辑器,支持标题、内容和摘要编写,
让你专注于内容创作。
</p>
</div>
<div className="card">
<h3 style={{
fontSize: '1.3rem',
marginBottom: '0.5rem',
color: '#059669'
}}>
📋 文章管理
</h3>
<p style={{ color: '#64748b' }}>
查看所有文章列表,支持阅读和删除操作,
轻松管理你的内容。
</p>
</div>
<div className="card">
<h3 style={{
fontSize: '1.3rem',
marginBottom: '0.5rem',
color: '#dc2626'
}}>
💾 文件存储
</h3>
<p style={{ color: '#64748b' }}>
使用 JSON 文件存储数据,简单可靠,
无需复杂的数据库配置。
</p>
</div>
</div>
</section>
{/* 技术栈 */}
<section>
<h2 style={{
fontSize: '2rem',
marginBottom: '1.5rem',
color: '#1e293b'
}}>
技术栈 ⚡
</h2>
<div style={{
display: 'flex',
justifyContent: 'center',
flexWrap: 'wrap',
gap: '1rem'
}}>
{[
{ name: 'Next.js', color: '#000000' },
{ name: 'TypeScript', color: '#3178c6' },
{ name: 'React', color: '#61dafb' },
{ name: 'CSS3', color: '#1572b6' }
].map((tech, index) => (
<span
key={index}
style={{
padding: '0.5rem 1rem',
backgroundColor: tech.color,
color: 'white',
borderRadius: '20px',
fontSize: '0.9rem',
fontWeight: '500'
}}
>
{tech.name}
</span>
))}
</div>
</section>
</div>
)
}
第八步:创建后端 API
在 app/api/posts/route.ts
文件中输入下面的 文章管理 API,在 app/api/posts/[id]/route.ts 文件中输入下面的 单篇文章 API,
文章管理 API
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
// 数据文件路径
const dataFilePath = path.join(process.cwd(), 'data', 'posts.json')
// 文章接口定义
interface Post {
id: string
title: string
content: string
summary: string
author: string
createdAt: string
}
interface PostsData {
posts: Post[]
nextId: number
}
// 读取文章数据
function readPostsFromFile(): PostsData {
try {
if (!fs.existsSync(dataFilePath)) {
const initialData: PostsData = { posts: [], nextId: 1 }
writePostsToFile(initialData)
return initialData
}
const fileContent = fs.readFileSync(dataFilePath, 'utf8')
return JSON.parse(fileContent)
} catch (error) {
console.error('读取文章文件失败:', error)
return { posts: [], nextId: 1 }
}
}
// 写入文章数据
function writePostsToFile(data: PostsData): boolean {
try {
const dataDir = path.dirname(dataFilePath)
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
fs.writeFileSync(dataFilePath, JSON.stringify(data, null, 2))
return true
} catch (error) {
console.error('写入文章文件失败:', error)
return false
}
}
// 获取所有文章 - GET请求
export async function GET() {
try {
const postsData = readPostsFromFile()
// 按创建时间倒序排列(最新的在前面)
const sortedPosts = postsData.posts.sort((a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)
return NextResponse.json({
posts: sortedPosts,
total: sortedPosts.length
})
} catch (error) {
console.error('获取文章列表失败:', error)
return NextResponse.json(
{ error: '获取文章列表失败' },
{ status: 500 }
)
}
}
// 创建新文章 - POST请求
export async function POST(request: NextRequest) {
try {
const { title, content, summary, author } = await request.json()
// 验证必填字段
if (!title || !content || !author) {
return NextResponse.json(
{ error: '标题、内容和作者不能为空' },
{ status: 400 }
)
}
// 验证字段长度
if (title.length > 100) {
return NextResponse.json(
{ error: '标题不能超过100个字符' },
{ status: 400 }
)
}
if (content.length > 10000) {
return NextResponse.json(
{ error: '内容不能超过10000个字符' },
{ status: 400 }
)
}
// 读取现有数据
const postsData = readPostsFromFile()
// 创建新文章
const newPost: Post = {
id: postsData.nextId.toString(),
title: title.trim(),
content: content.trim(),
summary: summary ? summary.trim() : content.trim().substring(0, 150) + '...',
author: author.trim(),
createdAt: new Date().toISOString()
}
// 添加到文章列表
postsData.posts.push(newPost)
postsData.nextId += 1
// 保存到文件
const success = writePostsToFile(postsData)
if (success) {
return NextResponse.json({
message: '文章创建成功!',
post: newPost
}, { status: 201 })
} else {
return NextResponse.json(
{ error: '保存文章失败,请重试' },
{ status: 500 }
)
}
} catch (error) {
console.error('创建文章API错误:', error)
return NextResponse.json(
{ error: '服务器错误,请重试' },
{ status: 500 }
)
}
}
单篇文章 API
import { NextRequest, NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
// 数据文件路径
const dataFilePath = path.join(process.cwd(), 'data', 'posts.json')
// 文章接口定义
interface Post {
id: string
title: string
content: string
summary: string
author: string
createdAt: string
}
interface PostsData {
posts: Post[]
nextId: number
}
// 读取文章数据
function readPostsFromFile(): PostsData {
try {
if (!fs.existsSync(dataFilePath)) {
return { posts: [], nextId: 1 }
}
const fileContent = fs.readFileSync(dataFilePath, 'utf8')
return JSON.parse(fileContent)
} catch (error) {
console.error('读取文章文件失败:', error)
return { posts: [], nextId: 1 }
}
}
// 写入文章数据
function writePostsToFile(data: PostsData): boolean {
try {
const dataDir = path.dirname(dataFilePath)
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true })
}
fs.writeFileSync(dataFilePath, JSON.stringify(data, null, 2))
return true
} catch (error) {
console.error('写入文章文件失败:', error)
return false
}
}
// 获取单篇文章 - GET请求
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const postId = params.id
const postsData = readPostsFromFile()
// 查找指定ID的文章
const post = postsData.posts.find(p => p.id === postId)
if (!post) {
return NextResponse.json(
{ error: '文章不存在' },
{ status: 404 }
)
}
return NextResponse.json({ post })
} catch (error) {
console.error('获取文章详情失败:', error)
return NextResponse.json(
{ error: '获取文章详情失败' },
{ status: 500 }
)
}
}
// 删除文章 - DELETE请求
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const postId = params.id
const postsData = readPostsFromFile()
// 查找文章索引
const postIndex = postsData.posts.findIndex(p => p.id === postId)
if (postIndex === -1) {
return NextResponse.json(
{ error: '文章不存在' },
{ status: 404 }
)
}
// 获取要删除的文章信息
const deletedPost = postsData.posts[postIndex]
// 从数组中删除文章
postsData.posts.splice(postIndex, 1)
// 保存到文件
const success = writePostsToFile(postsData)
if (success) {
return NextResponse.json({
message: '文章删除成功!',
deletedPost: {
id: deletedPost.id,
title: deletedPost.title
}
})
} else {
return NextResponse.json(
{ error: '删除文章失败,请重试' },
{ status: 500 }
)
}
} catch (error) {
console.error('删除文章API错误:', error)
return NextResponse.json(
{ error: '服务器错误,请重试' },
{ status: 500 }
)
}
}
// 更新文章 - PUT请求(可选功能)
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const postId = params.id
const { title, content, summary } = await request.json()
// 验证必填字段
if (!title || !content) {
return NextResponse.json(
{ error: '标题和内容不能为空' },
{ status: 400 }
)
}
const postsData = readPostsFromFile()
// 查找文章索引
const postIndex = postsData.posts.findIndex(p => p.id === postId)
if (postIndex === -1) {
return NextResponse.json(
{ error: '文章不存在' },
{ status: 404 }
)
}
// 更新文章
postsData.posts[postIndex] = {
...postsData.posts[postIndex],
title: title.trim(),
content: content.trim(),
summary: summary ? summary.trim() : content.trim().substring(0, 150) + '...'
}
// 保存到文件
const success = writePostsToFile(postsData)
if (success) {
return NextResponse.json({
message: '文章更新成功!',
post: postsData.posts[postIndex]
})
} else {
return NextResponse.json(
{ error: '更新文章失败,请重试' },
{ status: 500 }
)
}
} catch (error) {
console.error('更新文章API错误:', error)
return NextResponse.json(
{ error: '服务器错误,请重试' },
{ status: 500 }
)
}
}
第九步:创建写文章页面
在 app/write/page.tsx
文件中输入:
写文章页面
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function WritePage() {
const router = useRouter()
const [formData, setFormData] = useState({
title: '',
content: '',
summary: '',
author: '博主'
})
const [loading, setLoading] = useState(false)
const [message, setMessage] = useState('')
const [messageType, setMessageType] = useState<'success' | 'error'>('success')
// 处理输入框变化
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target
setFormData(prev => ({
...prev,
[name]: value
}))
}
// 提交表单
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setMessage('')
try {
const response = await fetch('/api/posts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
})
const data = await response.json()
if (response.ok) {
setMessage(`✅ ${data.message}`)
setMessageType('success')
// 清空表单
setFormData({
title: '',
content: '',
summary: '',
author: '博主'
})
// 3秒后跳转到文章详情页
setTimeout(() => {
router.push(`/blog/${data.post.id}`)
}, 2000)
} else {
setMessage(`❌ ${data.error}`)
setMessageType('error')
}
} catch (error) {
setMessage('❌ 网络错误,请重试')
setMessageType('error')
} finally {
setLoading(false)
}
}
// 字符计数
const titleCount = formData.title.length
const contentCount = formData.content.length
const summaryCount = formData.summary.length
return (
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
<div className="card">
<h1 style={{
fontSize: '2rem',
marginBottom: '0.5rem',
color: '#1e293b'
}}>
✍️ 写新文章
</h1>
<p style={{
color: '#64748b',
marginBottom: '2rem'
}}>
分享你的想法和见解
</p>
<form onSubmit={handleSubmit}>
{/* 标题输入 */}
<div className="form-group">
<label htmlFor="title">
文章标题 *
<span style={{
float: 'right',
color: titleCount > 100 ? '#dc2626' : '#64748b',
fontSize: '0.9rem'
}}>
{titleCount}/100
</span>
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
className="form-input"
placeholder="请输入文章标题..."
maxLength={100}
required
disabled={loading}
/>
</div>
{/* 摘要输入 */}
<div className="form-group">
<label htmlFor="summary">
文章摘要(可选)
<span style={{
float: 'right',
color: summaryCount > 200 ? '#dc2626' : '#64748b',
fontSize: '0.9rem'
}}>
{summaryCount}/200
</span>
</label>
<textarea
id="summary"
name="summary"
value={formData.summary}
onChange={handleInputChange}
className="form-input"
placeholder="简短描述文章内容(如果不填写,将自动从正文提取)..."
maxLength={200}
rows={3}
disabled={loading}
/>
</div>
{/* 作者输入 */}
<div className="form-group">
<label htmlFor="author">作者 *</label>
<input
type="text"
id="author"
name="author"
value={formData.author}
onChange={handleInputChange}
className="form-input"
placeholder="请输入作者名称..."
maxLength={50}
required
disabled={loading}
/>
</div>
{/* 正文输入 */}
<div className="form-group">
<label htmlFor="content">
文章正文 *
<span style={{
float: 'right',
color: contentCount > 10000 ? '#dc2626' : '#64748b',
fontSize: '0.9rem'
}}>
{contentCount}/10000
</span>
</label>
<textarea
id="content"
name="content"
value={formData.content}
onChange={handleInputChange}
className="form-input form-textarea"
placeholder="开始写你的文章内容..."
maxLength={10000}
required
disabled={loading}
/>
</div>
{/* 提交按钮 */}
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'flex-end'
}}>
<button
type="button"
onClick={() => router.back()}
className="btn btn-secondary"
disabled={loading}
>
取消
</button>
<button
type="submit"
className="btn btn-primary"
disabled={loading || !formData.title.trim() || !formData.content.trim()}
>
{loading ? '📝 发布中...' : '📝 发布文章'}
</button>
</div>
</form>
{/* 消息提示 */}
{message && (
<div className={`message message-${messageType}`} style={{ marginTop: '1.5rem' }}>
{message}
{messageType === 'success' && (
<div style={{ fontSize: '0.9rem', marginTop: '0.5rem' }}>
即将跳转到文章详情页...
</div>
)}
</div>
)}
</div>
{/* 写作提示 */}
<div className="card" style={{ marginTop: '1.5rem' }}>
<h3 style={{ fontSize: '1.2rem', marginBottom: '1rem', color: '#4338ca' }}>
💡 写作小贴士
</h3>
<ul style={{
listStyle: 'none',
padding: 0,
color: '#64748b',
lineHeight: '1.6'
}}>
<li style={{ marginBottom: '0.5rem' }}>
📝 <strong>标题</strong>:简洁明了,能概括文章主要内容
</li>
<li style={{ marginBottom: '0.5rem' }}>
📋 <strong>摘要</strong>:简短介绍,吸引读者继续阅读
</li>
<li style={{ marginBottom: '0.5rem' }}>
📖 <strong>正文</strong>:结构清晰,段落分明,表达准确
</li>
<li>
💾 <strong>保存</strong>:随时可以发布,数据会安全保存到文件中
</li>
</ul>
</div>
</div>
)
}
第十步:创建创建文章列表页面
在 app/blog/page.tsx
文件中输入:
文章列表页面
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
interface Post {
id: string
title: string
content: string
summary: string
author: string
createdAt: string
}
export default function BlogListPage() {
const [posts, setPosts] = useState<Post[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 获取文章列表
const fetchPosts = async () => {
try {
setLoading(true)
const response = await fetch('/api/posts')
const data = await response.json()
if (response.ok) {
setPosts(data.posts)
setError('')
} else {
setError(data.error || '获取文章列表失败')
}
} catch (error) {
setError('网络错误,请重试')
console.error('获取文章列表失败:', error)
} finally {
setLoading(false)
}
}
// 删除文章
const handleDeletePost = async (postId: string, postTitle: string) => {
const confirmed = window.confirm(`确定要删除文章 "${postTitle}" 吗?此操作不可恢复。`)
if (!confirmed) return
try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'DELETE'
})
const data = await response.json()
if (response.ok) {
// 从列表中移除已删除的文章
setPosts(prev => prev.filter(post => post.id !== postId))
alert(`✅ ${data.message}`)
} else {
alert(`❌ ${data.error}`)
}
} catch (error) {
alert('❌ 删除失败,请重试')
console.error('删除文章失败:', error)
}
}
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 页面加载时获取文章
useEffect(() => {
fetchPosts()
}, [])
if (loading) {
return (
<div className="loading">
<div style={{ fontSize: '1.2rem' }}>📚 加载文章中...</div>
</div>
)
}
if (error) {
return (
<div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ color: '#dc2626', marginBottom: '1rem' }}>❌ 加载失败</h2>
<p style={{ marginBottom: '1.5rem', color: '#64748b' }}>{error}</p>
<button
onClick={fetchPosts}
className="btn btn-primary"
>
🔄 重新加载
</button>
</div>
)
}
return (
<div style={{ maxWidth: '900px', margin: '0 auto' }}>
{/* 页面标题 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '2rem'
}}>
<div>
<h1 style={{
fontSize: '2.5rem',
marginBottom: '0.5rem',
color: '#1e293b'
}}>
📚 文章列表
</h1>
<p style={{ color: '#64748b' }}>
共 {posts.length} 篇文章
</p>
</div>
<Link href="/write" className="btn btn-primary">
✍️ 写新文章
</Link>
</div>
{/* 文章列表 */}
{posts.length === 0 ? (
<div className="empty-state">
<h3>📝 还没有文章</h3>
<p style={{ marginBottom: '1.5rem' }}>
开始写你的第一篇文章吧!
</p>
<Link href="/write" className="btn btn-primary">
✍️ 写第一篇文章
</Link>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{posts.map((post) => (
<article key={post.id} className="card">
{/* 文章标题 */}
<h2 style={{
fontSize: '1.5rem',
marginBottom: '0.5rem',
color: '#1e293b'
}}>
<Link
href={`/blog/${post.id}`}
style={{
textDecoration: 'none',
color: 'inherit',
transition: 'color 0.2s'
}}
onMouseEnter={(e) => e.currentTarget.style.color = '#4338ca'}
onMouseLeave={(e) => e.currentTarget.style.color = '#1e293b'}
>
{post.title}
</Link>
</h2>
{/* 文章元信息 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem',
marginBottom: '1rem',
fontSize: '0.9rem',
color: '#64748b'
}}>
<span>👤 {post.author}</span>
<span>📅 {formatDate(post.createdAt)}</span>
<span>📄 {post.content.length} 字</span>
</div>
{/* 文章摘要 */}
<p style={{
color: '#374151',
lineHeight: '1.6',
marginBottom: '1.5rem'
}}>
{post.summary}
</p>
{/* 操作按钮 */}
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Link
href={`/blog/${post.id}`}
className="btn btn-primary"
style={{ textDecoration: 'none' }}
>
📖 阅读全文
</Link>
<button
onClick={() => handleDeletePost(post.id, post.title)}
className="btn btn-danger"
style={{ fontSize: '0.9rem' }}
>
🗑️ 删除
</button>
</div>
</article>
))}
</div>
)}
{/* 刷新按钮 */}
{posts.length > 0 && (
<div style={{ textAlign: 'center', marginTop: '2rem' }}>
<button
onClick={fetchPosts}
className="btn btn-secondary"
>
🔄 刷新列表
</button>
</div>
)}
</div>
)
}
第十一步:创建文章详情页面
在 app/blog/[id]/page.tsx
文件中输入:
文章详情页面
'use client'
import { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Link from 'next/link'
interface Post {
id: string
title: string
content: string
summary: string
author: string
createdAt: string
}
export default function BlogDetailPage() {
const router = useRouter()
const params = useParams()
const postId = params.id as string
const [post, setPost] = useState<Post | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 获取文章详情
const fetchPost = async () => {
try {
setLoading(true)
const response = await fetch(`/api/posts/${postId}`)
const data = await response.json()
if (response.ok) {
setPost(data.post)
setError('')
} else {
setError(data.error || '获取文章失败')
}
} catch (error) {
setError('网络错误,请重试')
console.error('获取文章详情失败:', error)
} finally {
setLoading(false)
}
}
// 删除文章
const handleDeletePost = async () => {
if (!post) return
const confirmed = window.confirm(`确定要删除文章 "${post.title}" 吗?此操作不可恢复。`)
if (!confirmed) return
try {
const response = await fetch(`/api/posts/${postId}`, {
method: 'DELETE'
})
const data = await response.json()
if (response.ok) {
alert(`✅ ${data.message}`)
router.push('/blog') // 删除成功后跳转到文章列表
} else {
alert(`❌ ${data.error}`)
}
} catch (error) {
alert('❌ 删除失败,请重试')
console.error('删除文章失败:', error)
}
}
// 格式化日期
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
weekday: 'long'
})
}
// 格式化文章内容(将换行符转换为段落)
const formatContent = (content: string) => {
return content.split('\n').map((paragraph, index) => {
if (paragraph.trim() === '') return null
return (
<p key={index} style={{ marginBottom: '1rem' }}>
{paragraph}
</p>
)
}).filter(Boolean)
}
// 页面加载时获取文章
useEffect(() => {
if (postId) {
fetchPost()
}
}, [postId])
if (loading) {
return (
<div className="loading">
<div style={{ fontSize: '1.2rem' }}>📖 加载文章中...</div>
</div>
)
}
if (error) {
return (
<div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ color: '#dc2626', marginBottom: '1rem' }}>❌ 加载失败</h2>
<p style={{ marginBottom: '1.5rem', color: '#64748b' }}>{error}</p>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center' }}>
<button
onClick={fetchPost}
className="btn btn-primary"
>
🔄 重新加载
</button>
<Link href="/blog" className="btn btn-secondary">
📚 返回列表
</Link>
</div>
</div>
)
}
if (!post) {
return (
<div className="card" style={{ textAlign: 'center' }}>
<h2 style={{ color: '#64748b', marginBottom: '1rem' }}>📄 文章不存在</h2>
<p style={{ marginBottom: '1.5rem', color: '#64748b' }}>
您要查看的文章可能已被删除或不存在
</p>
<Link href="/blog" className="btn btn-primary">
📚 返回文章列表
</Link>
</div>
)
}
return (
<div style={{ maxWidth: '800px', margin: '0 auto' }}>
{/* 导航面包屑 */}
<nav style={{
marginBottom: '2rem',
padding: '1rem',
backgroundColor: '#f8fafc',
borderRadius: '8px',
fontSize: '0.9rem'
}}>
<Link href="/" style={{ color: '#4338ca', textDecoration: 'none' }}>
🏠 首页
</Link>
<span style={{ margin: '0 0.5rem', color: '#64748b' }}>/</span>
<Link href="/blog" style={{ color: '#4338ca', textDecoration: 'none' }}>
📚 文章列表
</Link>
<span style={{ margin: '0 0.5rem', color: '#64748b' }}>/</span>
<span style={{ color: '#64748b' }}>{post.title}</span>
</nav>
{/* 文章内容 */}
<article className="card">
{/* 文章标题 */}
<h1 className="article-title">
{post.title}
</h1>
{/* 文章元信息 */}
<div className="article-meta">
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
alignItems: 'center'
}}>
<span>👤 <strong>作者:</strong> {post.author}</span>
<span>📅 <strong>发布时间:</strong> {formatDate(post.createdAt)}</span>
<span>📊 <strong>字数:</strong> {post.content.length} 字</span>
<span>⏱️ <strong>预计阅读:</strong> {Math.ceil(post.content.length / 300)} 分钟</span>
</div>
</div>
{/* 文章摘要 */}
{post.summary && (
<div style={{
padding: '1rem',
backgroundColor: '#f8fafc',
borderLeft: '4px solid #4338ca',
marginBottom: '2rem',
borderRadius: '0 8px 8px 0'
}}>
<h3 style={{
fontSize: '1rem',
marginBottom: '0.5rem',
color: '#4338ca'
}}>
📋 文章摘要
</h3>
<p style={{ color: '#64748b', lineHeight: '1.6' }}>
{post.summary}
</p>
</div>
)}
{/* 文章正文 */}
<div className="article-content">
{formatContent(post.content)}
</div>
</article>
{/* 操作按钮 */}
<div className="card" style={{ marginTop: '1.5rem' }}>
<div style={{
display: 'flex',
gap: '1rem',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', gap: '1rem' }}>
<Link href="/blog" className="btn btn-secondary">
⬅️ 返回列表
</Link>
<Link href="/write" className="btn btn-primary">
✍️ 写新文章
</Link>
</div>
<button
onClick={handleDeletePost}
className="btn btn-danger"
>
🗑️ 删除文章
</button>
</div>
</div>
{/* 文章统计信息 */}
<div className="card" style={{ marginTop: '1.5rem' }}>
<h3 style={{
fontSize: '1.2rem',
marginBottom: '1rem',
color: '#4338ca'
}}>
📊 文章统计
</h3>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '1rem',
fontSize: '0.9rem'
}}>
<div style={{
padding: '0.75rem',
backgroundColor: '#f1f5f9',
borderRadius: '6px'
}}>
<div style={{ color: '#64748b' }}>总字数</div>
<div style={{ fontSize: '1.2rem', fontWeight: '600', color: '#1e293b' }}>
{post.content.length.toLocaleString()} 字
</div>
</div>
<div style={{
padding: '0.75rem',
backgroundColor: '#f1f5f9',
borderRadius: '6px'
}}>
<div style={{ color: '#64748b' }}>段落数</div>
<div style={{ fontSize: '1.2rem', fontWeight: '600', color: '#1e293b' }}>
{post.content.split('\n').filter(p => p.trim()).length} 段
</div>
</div>
<div style={{
padding: '0.75rem',
backgroundColor: '#f1f5f9',
borderRadius: '6px'
}}>
<div style={{ color: '#64748b' }}>预计阅读时间</div>
<div style={{ fontSize: '1.2rem', fontWeight: '600', color: '#1e293b' }}>
{Math.ceil(post.content.length / 300)} 分钟
</div>
</div>
</div>
</div>
</div>
)
}
如何运行和测试
cd my-blog
npm run dev
无报错后,直接打开本地浏览器,访问:http://localhost:3000 即可显示博客页面。
本文标题:Next.js 项目搭建:三分钟快速搭建个人博客系统
本文链接:https://uuzi.net/quick-start-nextjs-blog/
本文标签:博客搭建, 搭建教程
发布日期:2025年5月27日
更新日期:2025年5月27日
版权声明:兔哥原创内容,版权所有人为本网站作者,请勿转载,违者必究!
免责声明:文中如涉及第三方资源,均来自互联网,仅供学习研究,禁止商业使用,如有侵权,联系24小时内删除!