博客项目 laravel vue mysql 第六章 文章功能

# 前言

前面章节没看过的朋友请先从第一章开始看 。这章主要写文章相关功能。这里很多功能以后还要扩展一下。我这里只实现了部分功能,大家可以先看看。

后端

创建文章迁移文件

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;
    }
}

前端

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值