博客系统实战教程 — 从零到部署

适用人群:学完基础想做项目的开发者
学习时长:约1-2周
技术栈:Vue 3 + Laravel + MySQL + Redis
功能:文章管理、分类标签、评论系统、搜索

一、项目规划

1.1 功能清单

前端(Vue 3)
✅ 首页文章列表
✅ 文章详情页
✅ 分类/标签筛选
✅ 搜索功能
✅ 用户登录/注册
✅ 个人中心
✅ 文章发布/编辑

后端(Laravel)
✅ RESTful API
✅ JWT认证
✅ 文章CRUD
✅ 分类/标签管理
✅ 评论系统
✅ 搜索(全文检索)
✅ 缓存(Redis)

1.2 数据库设计

-- 用户表
CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    password VARCHAR(255) NOT NULL,
    nickname VARCHAR(50),
    avatar VARCHAR(255),
    bio TEXT,
    role ENUM('admin', 'editor', 'user') DEFAULT 'user',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- 文章表
CREATE TABLE posts (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    slug VARCHAR(200) UNIQUE NOT NULL,
    content TEXT NOT NULL,
    excerpt VARCHAR(500),
    cover_image VARCHAR(255),
    status ENUM('draft', 'published', 'archived') DEFAULT 'draft',
    views INT DEFAULT 0,
    author_id BIGINT NOT NULL,
    category_id BIGINT,
    published_at TIMESTAMP NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    FOREIGN KEY (author_id) REFERENCES users(id),
    FOREIGN KEY (category_id) REFERENCES categories(id),
    INDEX idx_slug (slug),
    INDEX idx_status (status),
    INDEX idx_published_at (published_at)
);

-- 分类表
CREATE TABLE categories (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    slug VARCHAR(50) UNIQUE NOT NULL,
    description VARCHAR(200),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 标签表
CREATE TABLE tags (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) UNIQUE NOT NULL,
    slug VARCHAR(50) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 文章标签关联表
CREATE TABLE post_tags (
    post_id BIGINT NOT NULL,
    tag_id BIGINT NOT NULL,
    PRIMARY KEY (post_id, tag_id),
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);

-- 评论表
CREATE TABLE comments (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    post_id BIGINT NOT NULL,
    user_id BIGINT,
    parent_id BIGINT,
    content TEXT NOT NULL,
    status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
    FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
);


二、后端实现(Laravel)

2.1 路由定义

// routes/api.php
Route::prefix('v1')->group(function () {
    // 认证
    Route::post('/auth/register', [AuthController::class, 'register']);
    Route::post('/auth/login', [AuthController::class, 'login']);
    
    // 公开接口
    Route::get('/posts', [PostController::class, 'index']);
    Route::get('/posts/{slug}', [PostController::class, 'show']);
    Route::get('/categories', [CategoryController::class, 'index']);
    Route::get('/tags', [TagController::class, 'index']);
    Route::get('/posts/{post}/comments', [CommentController::class, 'index']);
    
    // 需要认证的接口
    Route::middleware('auth:sanctum')->group(function () {
        Route::get('/user', [AuthController::class, 'user']);
        Route::post('/posts', [PostController::class, 'store']);
        Route::put('/posts/{post}', [PostController::class, 'update']);
        Route::delete('/posts/{post}', [PostController::class, 'destroy']);
        Route::post('/posts/{post}/comments', [CommentController::class, 'store']);
    });
});

2.2 控制器实现

// app/Http/Controllers/PostController.php
class PostController extends Controller
{
    public function index(Request $request)
    {
        $query = Post::with(['author', 'category', 'tags']);
        
        // 筛选
        if ($category = $request->input('category')) {
            $query->whereHas('category', fn($q) => $q->where('slug', $category));
        }
        
        if ($tag = $request->input('tag')) {
            $query->whereHas('tags', fn($q) => $q->where('slug', $tag));
        }
        
        if ($keyword = $request->input('keyword')) {
            $query->where(function ($q) use ($keyword) {
                $q->where('title', 'like', "%{$keyword}%")
                  ->orWhere('content', 'like', "%{$keyword}%");
            });
        }
        
        // 只显示已发布
        $query->where('status', 'published');
        
        // 排序和分页
        $posts = $query->orderBy('published_at', 'desc')
            ->paginate($request->input('per_page', 10));
        
        return PostResource::collection($posts);
    }
    
    public function show($slug)
    {
        $post = Post::where('slug', $slug)
            ->with(['author', 'category', 'tags'])
            ->firstOrFail();
        
        // 增加浏览量
        $post->increment('views');
        
        return new PostResource($post);
    }
    
    public function store(Request $request)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:200',
            'content' => 'required|string',
            'excerpt' => 'nullable|string|max:500',
            'category_id' => 'nullable|exists:categories,id',
            'tags' => 'nullable|array',
            'tags.*' => 'exists:tags,id',
        ]);
        
        $post = auth()->user()->posts()->create([
            ...$validated,
            'slug' => Str::slug($validated['title']),
            'status' => 'draft',
        ]);
        
        if (isset($validated['tags'])) {
            $post->tags()->sync($validated['tags']);
        }
        
        return new PostResource($post);
    }
}

2.3 模型定义

// app/Models/Post.php
class Post extends Model
{
    use HasFactory, SoftDeletes;
    
    protected $fillable = [
        'title', 'slug', 'content', 'excerpt', 
        'cover_image', 'status', 'author_id', 
        'category_id', 'published_at'
    ];
    
    protected $casts = [
        'published_at' => 'datetime',
    ];
    
    public function author()
    {
        return $this->belongsTo(User::class, 'author_id');
    }
    
    public function category()
    {
        return $this->belongsTo(Category::class);
    }
    
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
    
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}


三、前端实现(Vue 3)

3.1 API封装

// src/api/post.js
import request from '@/utils/request'

export const getPosts = (params) => request.get('/posts', { params })
export const getPost = (slug) => request.get(`/posts/${slug}`)
export const createPost = (data) => request.post('/posts', data)
export const updatePost = (id, data) => request.put(`/posts/${id}`, data)
export const deletePost = (id) => request.delete(`/posts/${id}`)

export const getCategories = () => request.get('/categories')
export const getTags = () => request.get('/tags')

3.2 文章列表页

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <!-- 搜索栏 -->
    <div class="search-bar">
      <input v-model="keyword" placeholder="搜索文章..." @input="debounceSearch" />
    </div>
    
    <!-- 分类筛选 -->
    <div class="categories">
      <span 
        v-for="cat in categories" 
        :key="cat.id"
        :class="{ active: currentCategory === cat.slug }"
        @click="filterByCategory(cat.slug)"
      >{{ cat.name }}</span>
    </div>
    
    <!-- 文章列表 -->
    <div class="post-list">
      <div v-for="post in posts" :key="post.id" class="post-card">
        <img v-if="post.cover_image" :src="post.cover_image" class="cover" />
        <div class="content">
          <h2>
            <router-link :to="`/post/${post.slug}`">{{ post.title }}</router-link>
          </h2>
          <p class="excerpt">{{ post.excerpt }}</p>
          <div class="meta">
            <span>{{ post.author?.nickname }}</span>
            <span>{{ formatDate(post.published_at) }}</span>
            <span>{{ post.views }} 次阅读</span>
          </div>
          <div class="tags">
            <span v-for="tag in post.tags" :key="tag.id" class="tag">
              {{ tag.name }}
            </span>
          </div>
        </div>
      </div>
    </div>
    
    <!-- 分页 -->
    <div class="pagination">
      <button :disabled="page <= 1" @click="page--; fetchPosts()">上一页</button>
      <span>{{ page }} / {{ totalPages }}</span>
      <button :disabled="page >= totalPages" @click="page++; fetchPosts()">下一页</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { getPosts, getCategories } from '@/api/post'

const posts = ref([])
const categories = ref([])
const keyword = ref('')
const currentCategory = ref('')
const page = ref(1)
const totalPages = ref(1)

const fetchPosts = async () => {
  const res = await getPosts({
    keyword: keyword.value,
    category: currentCategory.value,
    page: page.value,
    per_page: 10
  })
  posts.value = res.data
  totalPages.value = res.meta.last_page
}

const filterByCategory = (slug) => {
  currentCategory.value = slug
  page.value = 1
  fetchPosts()
}

const formatDate = (date) => new Date(date).toLocaleDateString('zh-CN')

onMounted(async () => {
  await fetchPosts()
  const res = await getCategories()
  categories.value = res.data
})
</script>


四、部署

# 后端部署
cd /www/wwwroot/blog-api
composer install --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache

# 前端部署
cd blog-frontend
npm run build
# 将dist目录上传到Nginx


学习建议

  1. 先完成后端API,确保接口正确
  2. 再做前端页面,调用API展示数据
  3. 逐步完善功能,先核心后辅助
  4. 测试每个功能,确保没有bug
  5. 部署上线,积累实战经验

下一步扩展

  • 添加Markdown编辑器
  • 实现全文搜索(Elasticsearch)
  • 添加图片上传(OSS)
  • 实现RSS订阅
  • 添加SEO优化
返回首页