适用人群:学完基础想做项目的开发者
学习时长:约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
学习建议
- 先完成后端API,确保接口正确
- 再做前端页面,调用API展示数据
- 逐步完善功能,先核心后辅助
- 测试每个功能,确保没有bug
- 部署上线,积累实战经验
下一步扩展
- 添加Markdown编辑器
- 实现全文搜索(Elasticsearch)
- 添加图片上传(OSS)
- 实现RSS订阅
- 添加SEO优化