# 前言
前面章节没看过的朋友请先从第一章开始看 。这章主要写文章相关功能。这里很多功能以后还要扩展一下。我这里只实现了部分功能,大家可以先看看。
后端
创建文章迁移文件
php artisan make:migration create_articles_table
创建文章和分类关联迁移文件
php artisan make:migration create_article_tag_table
编辑文章迁移文件
Schema::create('articles', function (Blueprint $table) {
$table->id(); // 主键,自增ID
$table->string('title'); // 文章标题
$table->string('slug')->unique(); // 文章别名,URL 友好,如"laravel-tutorial"
$table->longText('content'); // 文章内容,存储 Markdown 或 HTML
$table->text('summary')->nullable(); // 文章摘要,150-300 字,列表展示用
$table->string('cover', 500)->nullable(); // 封面图片 URL,如"storage/covers/xxx.jpg"
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null'); // 外键,关联用户表
$table->foreignId('category_id')->nullable()->constrained()->onDelete('set null'); // 外键,关联分类表
$table->enum('status', ['draft', 'published'])->default('draft'); // 文章状态:草稿或已发布
$table->unsignedInteger('view_count')->default(0); // 阅读量统计
$table->unsignedInteger('like_count')->default(0); // 点赞数统计
$table->boolean('is_top')->default(false); // 是否置顶
$table->string('seo_title')->nullable(); // SEO标题
$table->text('seo_description')->nullable(); // SEO描述
$table->text('seo_keywords')->nullable(); // SEO关键词
$table->timestamp('published_at')->nullable(); // 发布时间,发布时设置
$table->timestamps(); // 创建时间和更新时间
$table->index('status'); // 索引,优化按状态查询
$table->index('published_at'); // 索引,优化按发布时间排序
$table->index('is_top'); // 索引,优化置顶查询
});
编辑关联表迁移文件
Schema::create('article_tag', function (Blueprint $table) {
$table->id(); // 主键,自增ID
$table->foreignId('article_id')->constrained()->onDelete('cascade'); // 外键,关联文章表
$table->foreignId('tag_id')->constrained()->onDelete('cascade'); // 外键,关联标签表
$table->timestamps(); // 创建时间和更新时间
$table->unique(['article_id', 'tag_id']); // 联合唯一索引,防止重复关联
});
执行迁移命令
php artisan migrate
创建模型
php artisan make:model Article
编辑模型
protected $fillable = [
'title', 'slug', 'content', 'summary', 'cover', 'user_id', 'category_id',
'status', 'view_count', 'like_count', 'is_top', 'seo_title',
'seo_description', 'seo_keywords', 'published_at'
];
创建控制器
php artisan make:controller ArticleController
编辑控制器
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\Article;
use App\Models\Category;
class ArticleController extends Controller
{
/**
* 文章列表
* @param \Illuminate\Http\Request $request
* @return mixed|\Illuminate\Http\JsonResponse
*/
public function index(Request $request)
{
// 验证请求参数
$validated = $request->validate([
'per_page' => 'integer|min:1|max:50',
'page' => 'integer|min:1',
'category' => 'nullable|string|max:50',
'tags' => 'nullable|string', // 接受逗号分隔的字符串
'keyword' => 'nullable|string|max:100',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
], [
'per_page.integer' => '每页数量必须为整数',
'per_page.min' => '每页数量至少为1',
'per_page.max' => '每页数量最多为50',
'page.integer' => '页码必须为整数',
'page.min' => '页码至少为1',
'category.max' => '分类名称或别名不能超过50个字符',
'keyword.max' => '搜索关键词不能超过100个字符',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
]);
// 获取分页参数
$perPage = $validated['per_page'] ?? 10;
$page = $validated['page'] ?? 1;
// 构建查询
$query = Article::with(['category:id,name,slug', 'tags:id,name,slug'])
->select([
'id', 'title', 'slug', 'summary', 'cover', 'user_id', 'category_id',
'status', 'view_count', 'like_count', 'is_top', 'seo_title',
'seo_description', 'seo_keywords', 'published_at', 'created_at'
])
->orderBy('is_top', 'desc')
->orderBy('created_at', 'desc');
// 分类筛选
if ($category = $validated['category'] ?? null) {
$categoryId = Category::where('slug', $category)
->orWhere('id', $category)
->value('id');
if (!$categoryId) {
return response()->json([
'data' => [],
'meta' => [
'current_page' => 1,
'per_page' => $perPage,
'total' => 0,
'last_page' => 1,
],
'links' => [
'prev' => null,
'next' => null,
],
'message' => '无效的分类',
], 200);
}
$query->where('category_id', $categoryId);
}
// 标签筛选
if ($tags = $validated['tags'] ?? null) {
$tags = array_filter(explode(',', $tags));
if ($tags) {
$tagIds = Tag::whereIn('slug', $tags)->pluck('id')->toArray();
if (empty($tagIds)) {
return response()->json([
'data' => [],
'meta' => [
'current_page' => 1,
'per_page' => $perPage,
'total' => 0,
'last_page' => 1,
],
'links' => [
'prev' => null,
'next' => null,
],
'message' => '未找到匹配的标签',
], 200);
}
$query->whereHas('tags', fn($q) => $q->whereIn('tags.id', $tagIds));
}
}
// 关键词搜索
if ($keyword = $validated['keyword'] ?? null) {
$keyword = trim($keyword);
if ($keyword !== '') {
$query->where(function ($q) use ($keyword) {
$q->where('title', 'like', "%{$keyword}%")
->orWhere('summary', 'like', "%{$keyword}%")
->orWhere('content', 'like', "%{$keyword}%");
});
}
}
// 状态筛选
if ($status = $validated['status'] ?? null) {
$query->where('status', $status);
}
// 置顶筛选
if (isset($validated['is_top'])) {
$query->where('is_top', $validated['is_top']);
}
// 执行分页查询
$articles = $query->paginate($perPage, ['*'], 'page', $page);
// 返回json
return response()->json([
'data' => $articles->items(),
'meta' => [
'current_page' => $articles->currentPage(),
'per_page' => $articles->perPage(),
'total' => $articles->total(),
'last_page' => $articles->lastPage(),
],
'links' => [
'prev' => $articles->previousPageUrl(),
'next' => $articles->nextPageUrl(),
],
'message' => $articles->isEmpty() ? '暂无文章' : '获取文章列表成功',
], 200);
}
/**
* 显示详细
* @param \App\Models\Article $article
* @return mixed|\Illuminate\Http\JsonResponse
*/
public function show(Article $article)
{
// 预加载分类和标签
$article = Article::with(['category:id,name,slug', 'tags:id,name,slug'])
->select([
'id', 'title', 'slug', 'content', 'summary', 'cover',
'user_id', 'category_id', 'view_count', 'like_count', 'is_top',
'seo_title', 'seo_description', 'seo_keywords', 'published_at',
'status', 'created_at'
])
->findOrFail($article->id);
// 添加 cover_url 字段
$article->cover_url = $article->cover ? asset('storage/' . $article->cover) : null;
// 返回 JSON
return response()->json([
'data' => $article,
'message' => '获取文章成功'
], 200);
}
/**
* 创建新文章
*/
public function store(Request $request)
{
// 验证请求数据
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'summary' => 'nullable|string|max:500',
'cover' => 'nullable|string|max:500',
'category_id' => 'required|exists:categories,id',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:500',
], [
'title.required' => '文章标题不能为空',
'title.max' => '文章标题不能超过255个字符',
'content.required' => '文章内容不能为空',
'summary.max' => '摘要不能超过500个字符',
'cover.max' => '封面路径不能超过500个字符',
'category_id.required' => '分类ID不能为空',
'category_id.exists' => '分类不存在',
'tags.*.exists' => '标签ID无效',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
'seo_title.max' => 'SEO标题不能超过255个字符',
'seo_description.max' => 'SEO描述不能超过500个字符',
'seo_keywords.max' => 'SEO关键词不能超过500个字符',
]);
try {
// 创建文章
$article = Article::create([
'title' => $validated['title'],
'slug' => $this->generateUniqueSlug($validated['title']),
'content' => $validated['content'],
'summary' => $validated['summary'] ?? null,
'cover' => $validated['cover'] ?? null,
'category_id' => $validated['category_id'],
'user_id' => auth('sanctum')->id(),
'status' => $validated['status'] ?? 'draft',
'is_top' => $validated['is_top'] ?? false,
'seo_title' => $validated['seo_title'] ?? null,
'seo_description' => $validated['seo_description'] ?? null,
'seo_keywords' => $validated['seo_keywords'] ?? null,
'published_at' => ($validated['status'] ?? 'draft') === 'published' ? now() : null,
]);
// 关联标签
if (!empty($validated['tags'])) {
$article->tags()->sync($validated['tags']);
}
// 预加载关联数据
$article->load(['category:id,name,slug', 'tags:id,name,slug']);
return response()->json([
'data' => [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'content' => $article->content,
'summary' => $article->summary,
'cover' => $article->cover,
'cover_url' => $article->cover ? asset('storage/' . $article->cover) : null,
'category_id' => $article->category_id,
'user_id' => $article->user_id,
'status' => $article->status,
'is_top' => $article->is_top,
'seo_title' => $article->seo_title,
'seo_description' => $article->seo_description,
'seo_keywords' => $article->seo_keywords,
'published_at' => $article->published_at,
'category' => $article->category,
'tags' => $article->tags,
],
'message' => '创建文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '创建文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 更新文章
*/
public function update(Request $request, Article $article)
{
// 验证用户权限
if ($article->user_id !== auth('sanctum')->id()) {
return response()->json([
'message' => '无权更新此文章'
], 403);
}
// 验证请求数据
$validated = $request->validate([
'title' => 'required|string|max:255',
'content' => 'required|string',
'summary' => 'nullable|string|max:500',
'cover' => 'nullable|string|max:500',
'category_id' => 'required|exists:categories,id',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'status' => 'nullable|in:draft,published',
'is_top' => 'nullable|boolean',
'seo_title' => 'nullable|string|max:255',
'seo_description' => 'nullable|string|max:500',
'seo_keywords' => 'nullable|string|max:500',
], [
'title.required' => '文章标题不能为空',
'title.max' => '文章标题不能超过255个字符',
'content.required' => '文章内容不能为空',
'summary.max' => '摘要不能超过500个字符',
'cover.max' => '封面路径不能超过500个字符',
'category_id.required' => '分类ID不能为空',
'category_id.exists' => '分类不存在',
'tags.*.exists' => '标签ID无效',
'status.in' => '状态必须为草稿或已发布',
'is_top.boolean' => '置顶状态必须为布尔值',
'seo_title.max' => 'SEO标题不能超过255个字符',
'seo_description.max' => 'SEO描述不能超过500个字符',
'seo_keywords.max' => 'SEO关键词不能超过500个字符',
]);
try {
// 更新文章
$updateData = [
'title' => $validated['title'],
'slug' => $this->generateUniqueSlug($validated['title'], $article->id),
'content' => $validated['content'],
'summary' => $validated['summary'] ?? null,
'cover' => $validated['cover'] ?? null,
'category_id' => $validated['category_id'],
'status' => $validated['status'] ?? $article->status,
'is_top' => $validated['is_top'] ?? $article->is_top,
'seo_title' => $validated['seo_title'] ?? null,
'seo_description' => $validated['seo_description'] ?? null,
'seo_keywords' => $validated['seo_keywords'] ?? null,
'published_at' => $article->status === 'draft' && ($validated['status'] ?? $article->status) === 'published' ? now() : $article->published_at,
];
$article->update($updateData);
// 关联标签
if (isset($validated['tags'])) {
$article->tags()->sync($validated['tags']);
}
// 预加载关联数据
$article->load(['category:id,name,slug', 'tags:id,name,slug']);
return response()->json([
'data' => [
'id' => $article->id,
'title' => $article->title,
'slug' => $article->slug,
'content' => $article->content,
'summary' => $article->summary,
'cover' => $article->cover,
'cover_url' => $article->cover ? asset('storage/' . $article->cover) : null,
'category_id' => $article->category_id,
'user_id' => $article->user_id,
'status' => $article->status,
'is_top' => $article->is_top,
'seo_title' => $article->seo_title,
'seo_description' => $article->seo_description,
'seo_keywords' => $article->seo_keywords,
'published_at' => $article->published_at,
'category' => $article->category,
'tags' => $article->tags,
],
'message' => '更新文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '更新文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 删除文章
*/
public function destroy(Article $article)
{
// 验证用户权限
if ($article->user_id !== auth('sanctum')->id()) {
return response()->json([
'message' => '无权删除此文章'
], 403);
}
try {
// 删除关联标签
$article->tags()->detach();
$article->delete();
return response()->json([
'message' => '删除文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '删除文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 批量删除文章
*/
public function destroyBatch(Request $request)
{
// 验证请求数据
$validated = $request->validate([
'ids' => 'required|array|min:1',
'ids.*' => 'integer|exists:articles,id',
], [
'ids.required' => '文章ID列表不能为空',
'ids.array' => '文章ID列表必须为数组',
'ids.min' => '文章ID列表不能为空',
'ids.*.integer' => '文章ID必须为整数',
'ids.*.exists' => '文章ID不存在',
]);
try {
// 查询用户拥有的文章
$articles = Article::whereIn('id', $validated['ids'])
->where('user_id', auth('sanctum')->id())
->get();
if ($articles->isEmpty()) {
return response()->json([
'message' => '无权删除或文章不存在'
], 403);
}
// 批量删除
$deletedCount = 0;
foreach ($articles as $article) {
$article->tags()->detach();
$article->delete();
$deletedCount++;
}
return response()->json([
'data' => [
'deleted_count' => $deletedCount
],
'message' => '批量删除文章成功'
], 200);
} catch (\Exception $e) {
return response()->json([
'message' => '批量删除文章失败: ' . $e->getMessage()
], 500);
}
}
/**
* 生成唯一 slug
*
* @param string $title
* @param int|null $excludeId
* @return string
*/
protected function generateUniqueSlug(string $title, $excludeId = null)
{
$slug = Str::slug($title);
$originalSlug = $slug;
$count = 1;
while (Article::where('slug', $slug)
->where('id', '!=', $excludeId)
->exists()) {
$slug = $originalSlug . '-' . $count++;
}
return $slug;
}
}