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

前言

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

后端

创建文章迁移文件

php artisan make:migration create_articles_table

创建文章和分类关联迁移文件

php artisan make:migration create_article_tag_table

创建图片表

php artisan make:migration create_images_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('restrict'); // 外键,关联分类表
            $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->unique(['article_id', 'tag_id']); // 联合唯一索引,防止重复关联
});

编辑图片表

Schema::create('images', function (Blueprint $table) {
    $table->id();
    $table->string('url'); // 图片 URL
    $table->string('path'); // 图片存储路径
    $table->unsignedBigInteger('article_id')->nullable(); // 关联文章 ID
    $table->unsignedBigInteger('user_id'); // 上传者 ID
    $table->enum('type', ['cover', 'content']); // 图片类型:封面或内容图片
    $table->enum('status', ['temporary', 'permanent'])->default('temporary'); // 状态:临时或正式
    $table->timestamps();

    $table->foreign('article_id')->references('id')->on('articles')->onDelete('set null');
    $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
    $table->index('status');
    $table->index('article_id');
});

执行迁移命令

php artisan migrate

创建模型

php artisan make:model Article

编辑模型

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Traits\HasUniqueSlug;

class Article extends Model
{
    // 添加 HasFactory 和 HasUniqueSlug 特性
    use HasFactory, HasUniqueSlug;

    // 定义可批量赋值的字段
    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',
    ];

    /**
     * 定义文章与分类之间的一对多关系(属于某个分类)。
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function category()
    {
        return $this->belongsTo(Category::class);
    }

    /**
     * 定义文章与标签之间的多对多关系。
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
     */
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

    /**
     * 定义文章与用户(作者)之间的一对多关系(属于某个用户)。
     *
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

编辑种子文件database/seeders/DatabaseSeeder.php

// 文章
$articles = [
    [
        'title' => '深入掌握 PHP 8 新特性:从 JIT 到属性',
        'slug' => 'php-8-new-features',
        'content' => 'PHP 8 引入了许多激动人心的新特性,包括 JIT 编译器、属性(Attributes)、联合类型等。本文将详细解析这些新特性的原理和用法,通过实际代码示例展示如何在项目中应用它们以提升性能和代码可读性。',
        'summary' => '探索 PHP 8 的 JIT、属性和联合类型,提升开发效率。',
        'cover' => 'storage/covers/php8.png',
        'user_id' => $admin->id,
        'category_id' => $category[0]->id,
        'status' => 'published',
        'view_count' => 0,
        'like_count' => 0,
        'is_top' => false,
        'seo_title' => 'PHP 8 新特性详解',
        'seo_description' => '深入了解 PHP 8 的 JIT、属性和联合类型,提升你的开发效率。',
        'seo_keywords' => 'PHP 8, JIT, 属性, 联合类型',
        'published_at' => now(),
        'tags' => ['PHP', 'Web 开发']
    ],
    [
        'title' => 'React Hooks 深度剖析:从 useState 到 useEffect',
        'slug' => 'react-hooks-deep-dive',
        'content' => 'React Hooks 彻底改变了组件开发的模式。本文深入探讨 useState、useEffect 等核心 Hooks 的工作原理和最佳实践,通过实际案例帮助开发者更高效地构建 React 应用。',
        'summary' => '深入剖析 React Hooks 的核心原理与使用技巧。',
        'cover' => 'storage/covers/react.png',
        'user_id' => $admin->id,
        'category_id' => $category[0]->id,
        'status' => 'published',
        'view_count' => 0,
        'like_count' => 0,
        'is_top' => false,
        'seo_title' => 'React Hooks 完全指南',
        'seo_description' => '学习 React Hooks 的核心概念,掌握 useState 和 useEffect 的最佳实践。',
        'seo_keywords' => 'React, Hooks, useState, useEffect',
        'published_at' => now(),
        'tags' => ['React', 'JavaScript']
    ],
    [
        'title' => 'Kubernetes 入门与实践:构建可扩展的容器化应用',
        'slug' => 'kubernetes-for-beginners',
        'content' => 'Kubernetes 是容器编排的行业标准。本教程从 Kubernetes 的基本概念入手,逐步介绍如何部署 Pod、管理 Service 和使用 Helm 进行应用管理,帮助初学者快速上手并构建可扩展的容器化应用。',
        'summary' => '面向初学者的 Kubernetes 教程,涵盖部署和管理容器化应用的核心技能。',
        'cover' => 'storage/covers/kubernetes.png',
        'user_id' => $admin->id,
        'category_id' => $category[2]->id,
        'status' => 'published',
        'view_count' => 0,
        'like_count' => 0,
        'is_top' => false,
        'seo_title' => 'Kubernetes 入门教程',
        'seo_description' => '学习 Kubernetes 的核心概念,快速部署和管理容器化应用。',
        'seo_keywords' => 'Kubernetes, 容器化, DevOps',
        'published_at' => now(),
        'tags' => ['Kubernetes', 'DevOps']
    ],
];

foreach ($articles as $articleData) {
    $tagsToSync = $articleData['tags'];
    unset($articleData['tags']);

    $article = Article::create($articleData);

    $tagIds = Tag::whereIn('name', $tagsToSync)->pluck('id');
    $article->tags()->sync($tagIds);
}

运行种子文件命令

php artisan migrate:fresh
php artisan db:seed

修改traits

    /**
     * 在模型创建或更新时自动生成唯一的 slug
     *
     * @return void
     */
    protected static function bootHasUniqueSlug()
    {
        // 监听模型创建事件
        static::creating(function (Model $model) {
            $name = $model->name ?? $model->title;
            if ($name) {
                $model->slug = self::generateUniqueSlug($name, null);
            }
        });

        // 监听模型更新事件
        static::updating(function (Model $model) {
            // 只有当 name 或 title 字段被修改时才重新生成 slug
            if ($model->isDirty('name') || $model->isDirty('title')) {
                $name = $model->name ?? $model->title;
                if ($name) {
                    $model->slug = self::generateUniqueSlug($name, $model->id);
                }
            }
        });
    }

创建控制器

php artisan make:controller ArticleController

编辑控制器

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use App\Models\Article;
use App\Models\Tag;
use App\Models\Image;
use Illuminate\Support\Str;

class ArticleController extends Controller
{
    /**
     * 文章列表
     */
    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 ($categoryId = $validated['category'] ?? null) {
            $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}%");
                });
            }
        }

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

        return response()->json([
            'data' => $articles->items(),
            'pagination' => [
                '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);
    }

    /**
     * 显示详细
     */
    public function show(Article $article)
    {
        $article->load(['category:id,name,slug', 'tags:id,name,slug']);
        $article->cover_url = $article->cover ? asset('storage/' . $article->cover) : null;

        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_id' => 'nullable|exists:images,id',
            '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_id.exists' => '封面图片ID无效',
            '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' => null,
                'category_id' => $validated['category_id'],
                'user_id' => auth('sanctum')->id(),
                'status' => $validated['status'] ?? 'published',
                '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'] ?? 'published') === 'published' ? now() : null,
            ]);

            if (!empty($validated['tags'])) {
                $article->tags()->sync($validated['tags']);
            }

            // 关联图片
            $this->associateImages($article, $validated['cover_id'] ?? null);

            $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)
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'content' => 'required|string',
            'summary' => 'nullable|string|max:500',
            'cover_id' => 'nullable|exists:images,id',
            '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_id.exists' => '封面图片ID无效',
            '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,
                '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 === 'published' && ($validated['status'] ?? $article->status) === 'published' ? now() : $article->published_at,
            ];

            $article->update($updateData);

            if (isset($validated['tags'])) {
                $article->tags()->sync($validated['tags']);
            }

            // 关联图片
            $this->associateImages($article, $validated['cover_id'] ?? null);

            $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)
    {
        try {
            // 删除关联标签
            $article->tags()->detach();
            // 删除关联图片
            Image::where('article_id', $article->id)->delete();
            $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();
                Image::where('article_id', $article->id)->delete();
                $article->delete();
                $deletedCount++;
            }

            return response()->json([
                'data' => [
                    'deleted_count' => $deletedCount
                ],
                'message' => '批量删除文章成功'
            ], 200);
        } catch (\Exception $e) {
            return response()->json([
                'message' => '批量删除文章失败: ' . $e->getMessage()
            ], 500);
        }
    }

    /**
     * 关联图片到文章
     */
    protected function associateImages(Article $article, $coverId = null)
    {
        // 处理封面图片
        if ($coverId) {
            $coverImage = Image::where('id', $coverId)
                ->where('user_id', auth('sanctum')->id())
                ->where('type', 'cover')
                ->where('status', 'temporary')
                ->first();
            if ($coverImage) {
                $coverImage->update([
                    'article_id' => $article->id,
                    'status' => 'permanent',
                ]);
                $article->cover = $coverImage->path;
            }
        }

        // 处理内容图片
        preg_match_all('/<img[^>]+src="([^">]+)"/i', $article->content, $matches);
        foreach ($matches[1] as $url) {
            $path = str_replace(asset('storage/'), '', $url);
            $image = Image::where('path', $path)
                ->where('user_id', auth('sanctum')->id())
                ->where('type', 'content')
                ->where('status', 'temporary')
                ->first();
            if ($image) {
                $image->update([
                    'article_id' => $article->id,
                    'status' => 'permanent',
                ]);
            }
        }
        $article->save();
    }

    /**
     * 生成唯一 slug
     */
    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;
    }
}

编辑app/Http/Controller/UploadController.php

<?php
namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use App\Models\User;
use App\Models\Image;
use Intervention\Image\Facades\Image as ImageFacade;

class UploadController extends Controller {

    /**
     * 上传头像
     * @param \Illuminate\Http\Request $request
     * @return mixed|\Illuminate\Http\JsonResponse
     */
    public function uploadAvatar(Request $request)
    {
        $request->validate([
            'avatar' => 'required|image|mimes: jpeg,png,jpg,gif|max:2048'
        ]);
        try{
            $file = $request->file('avatar');
            // 生成文件名
            $fileName = 'avatar_1_' . time() . '.' . $file->getClientOriginalExtension();
            // 保存文件到存储路径
            $path = $file->storeAs('avatars', $fileName, 'public');

            $user = User::find(1);
            if($user->avatar && Storage::disk('public')->exists($user->avatar)) {
                // 删除旧头像
                Storage::disk('public')->delete($user->avatar);
            }

            // 更新用户头像路径
            $user->update(['avatar' => $path]);
            return response()->json([
                'data' => [
                    'avatar_url' => asset('storage/'. $path),
                    'avatar_path' => $path,
                ],
                'message' => '头像上传成功',
            ], 200);

        }catch (\Exception $e){
            return response()->json([
                'message' => '头像上传失败' . $e->getMessage()
            ], 500);
        }
    }

    /**
     * 上传文章封面
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Http\JsonResponse
     */
    public function uploadCover(Request $request)
    {
        $request->validate([
            'cover' => 'required|image|mimes:jpeg,png,jpg,gif|max:2048'
        ], [
            'cover.required' => '请上传封面图片',
            'cover.image' => '文件必须为图片',
            'cover.mimes' => '仅支持 jpeg,png,jpg,gif,webp 格式',
            'cover.max' => '图片大小不能超过 2MB',
        ]);

        try{
            $file = $request->file('cover');
            $fileName = 'cover_' . auth('sanctum')->id() . '_' . time() . '.' . '.webp';
            $path = 'covers/' . date('Y/m') . '/' . $fileName;

            // 压缩并转为 WebP
            $image = ImageFacade::make($file)
                ->resize(1200, null, function ($constraint) {
                    $constraint->aspectRatio();
                    $constraint->upsize();
                })
                ->encode('webp', 80);
            Storage::disk('public')->put($path, $image);

            // 记录到 images 表
            $imageRecord = Image::create([
                'url' => asset('storage/' . $path),
                'path' => $path,
                'user_id' => auth('sanctum')->id(),
                'type' => 'cover',
                'status' => 'temporary',
            ]);

            return response()->json([
                'data' => [
                    'cover_url' => $imageRecord->url,
                    'cover_id' => $imageRecord->id,
                    'cover_path' => $path,
                ],
                'message' => '封面上传成功',
            ], 200);

        } catch (\Exception $e){
            return response()->json([
                'message' => '封面上传失败' . $e->getMessage()
            ], 500);
        }
    }

    /**
     * 上传内容图片
     */
    public function uploadImage(Request $request)
    {
        $request->validate([
            'image' => 'required|image|mimes:jpeg,png,jpg,gif,webp|max:2048',
        ], [
            'image.required' => '请上传图片',
            'image.image' => '文件必须为图片',
            'image.mimes' => '仅支持 jpeg,png,jpg,gif,webp 格式',
            'image.max' => '图片大小不能超过 2MB',
        ]);

        try {
            $file = $request->file('image');
            $fileName = 'image_' . auth('sanctum')->id() . '_' . time() . '.webp';
            $path = 'images/' . date('Y/m') . '/' . $fileName;

            // 压缩并转为 WebP
            $image = ImageFacade::make($file)
                ->resize(1200, null, function ($constraint) {
                    $constraint->aspectRatio();
                    $constraint->upsize();
                })
                ->encode('webp', 80);
            Storage::disk('public')->put($path, $image);

            // 记录到 images 表
            $imageRecord = Image::create([
                'url' => asset('storage/' . $path),
                'path' => $path,
                'user_id' => auth('sanctum')->id(),
                'type' => 'content',
                'status' => 'temporary',
            ]);

            return response()->json([
                'data' => [
                    'image_url' => $imageRecord->url,
                    'image_id' => $imageRecord->id,
                    'image_path' => $path,
                ],
                'message' => '图片上传成功',
            ], 200);
        } catch (\Exception $e) {
            return response()->json([
                'message' => '图片上传失败: ' . $e->getMessage()
            ], 500);
        }
    }

    
    /**
     * 清理未使用的图片
     */
    public function cleanUnusedImages(Request $request)
    {
        try {
            // 确保只有授权用户可以清理(可根据需要添加权限控制)
            $request->user('sanctum')->id();

            // 查询 7 天前的临时图片
            $images = Image::where('status', 'temporary')
                ->where('created_at', '<', now()->subDays(7))
                ->where('user_id', auth('sanctum')->id()) // 限制为当前用户
                ->get();

            $deletedCount = 0;
            foreach ($images as $image) {
                Storage::disk('public')->delete($image->path);
                $image->delete();
                $deletedCount++;
            }

            return response()->json([
                'data' => [
                    'deleted_count' => $deletedCount,
                ],
                'message' => "已清理 {$deletedCount} 张未使用图片",
            ], 200);
        } catch (\Exception $e) {
            return response()->json([
                'message' => '清理图片失败: ' . $e->getMessage()
            ], 500);
        }
    }
}

修改路由routes/api.php

// 文章
Route::prefix('articles')->group(function () {
    Route::get('/', [ArticleController::class, 'index']);  // 文章列表
    Route::get('/{article}', [ArticleController::class, 'show']);  // 文章详细
    Route::post('/', [ArticleController::class, 'store']);  // 创建文章
    Route::put('/{article}', [ArticleController::class, 'update']); // 更新文章
    Route::delete('/{article}', [ArticleController::class, 'destroy']);  // 删除文章
    Route::delete('/batch', [ArticleController::class, 'destroyBatch']);  // 批量删除文章
});

// 上传
Route::prefix('upload')->group(function() {
     Route::post('/avatar', [UploadController::class, 'uploadAvatar']); // 上传头像
     Route::post('/cover', [UploadController::class, 'uploadCover']); // 上传封面
     Route::post('/image', [UploadController::class, 'uploadImage']); // 上传文章内容图片
});

前端

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值