跳至正文

Next.js 项目搭建:三分钟快速搭建个人博客系统

免责声明: 本文基于个人经验分享,内容可能因时间、地区或个人情况而异。操作前请结合实际情况判断,必要时查询最新官方信息。如有疑问或建议,欢迎留言交流。

作为一个毫无编程经验的小白来说,学习 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 即可显示博客页面。

如本文“对您有用”,欢迎随意打赏兔哥,让我们坚持创作!

   

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注