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

了解你的GPU:深入探讨AMD SMI 2024年9月17日 作者:Matt Elliott对于使用AMD硬件的系统管理员和高级用户来说,性能优化和高效的资源监控至关重要。AMD系统管理界面命令行工具`amd-smi`正是为了解决这些需求而设计的。amd-smi是一个多功能的命令行工具,专门用于管理和监控AMD硬件,主要侧重于GPU。作为未来取代`rocm-smi`的工具,`amd-smi`有望成为跨广泛设备的AMD硬件管理的主要工具。 阅读详情

前言

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

后端

创建文章迁移文件

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']); // 上传文章内容图片
});

前端

Python与CAD系列基础篇(十)图形复制、移动、删除 ①通过pyautocad连接AutoCAD进行处理②通过ezdxf处理dxf格式文件。进行图形复制、移动、删除的方法。pyautocad方式。pyautocad方式。 阅读详情

相关推荐

详解相机的sensor工作原理

目录sensor成像原理拜尔马赛克变换(BAYER DEMOSAICING)维伦阵列摩尔纹 sensor成像原理 相机或摄像头使用数百万个微小的光腔(感光元件)来记录图像。 成像时,sensor将镜头投射来的光子储存为模拟电信号。 曝光完成后,相机关闭这些光腔(感光元件),然后通过测量模拟电信号的强度来评估每个腔中有多少光子掉入来计算数字电信号的数值。 然后模拟电信号转换为数字电信号,数字电信号的数值由位深度决定(RGB的位深度)。 最后,根据正在记录的文件格式(8位JPEG位0-255),可能会再次降低

路人coder 2万+

laravel意庐甜品蛋糕商城网站商城 博客laravel vue

数据库工具:Navicat/SQLyog等都可以前端开发框架:vue.js数据库 mysql 版本不限开发工具pycharm;Hbuilderx;数据库管理软件:Navicat/SQLyog;前端页面数据处理传输以及页面展示使用Vue技术;采用B/S架构PHP是英文超文本预处理语言Hypertext Preprocessor的缩写。PHP 是一种 HTML 内嵌式的语言,是一种在服务器端执行的嵌入HTML文档的脚本语言,语言的风格有类似于C语言,被广泛地运用flask。

鹏飞的博客 964

OpenWrt kmod内核不兼容?手把手教你用脚本强制安装驱动模块(附国内镜像源)

本文详细解决了OpenWrt kmod内核不兼容问题,提供智能脚本强制安装驱动模块的方法,并附有国内镜像源加速配置。通过自动化脚本修改内核MD5校验值,确保kmod包正常安装,同时推荐多个国内镜像源以提升下载速度。

weixin_42549451的博客 542

vueDiff算法

【代码】vueDiff算法。

qq_35359474的博客 91

Autosar开发实战:DaVinci Configurator Pro新建工程保姆级教程(附目录结构解析)

本文详细介绍了使用DaVinci Configurator Pro创建Autosar工程的完整流程,包括环境准备、工程配置、目录结构解析及常见问题排查。通过实战演示和深度解读,帮助开发者快速掌握工具使用技巧,优化工程管理,提升开发效率。特别适合需要理解Autosar开发中DaVinci Config工具链的工程师。

weixin_30872157的博客 458

Masta调用Geometry Modeller(Space Claim)进行根据三维模型快速建模设置方法

masta中调用ANSYS的space claim的设置方法

xifegnlie的博客 244

vue 表格 vxe-table 如何让下拉筛选弹窗根据屏幕自动更新位置

vxe-table 通过 filterConfig.transfer: true 将筛选面板渲染至 document.body,解决因容器 overflow: hidden 导致的面板裁剪问题。面板基于视口自动定位,空间不足时向上翻转,滚动与窗口缩放时动态更新位置,确保始终可见且不被遮挡。生效前提:列需配置 filterRender,表格需有固定高度或处于可滚动容器中。该配置为表格级设置,适用于所有启用了筛选的列。

chocolate2s的博客 253

寻迹Arduino智能小车

Arduino巡线寻迹智能小车

Rachael_Wang的博客 1万+

前端登录页面的书写与验证(后端)

如果有该用户就登录成功跳转至主页,如果没有用户和密码错误就登录失败不跳转。(3)写后端代码,接受前端请求并返回数据(新建目录houduan,在houduan/index.js写入)(2)前端配置路由地址,方便登录成功后页面的跳转(在routers.js写入)输入admin和123456,就可以实现登录并跳转至主页(内容暂时没有写)了。(4)后端操作数据库拿数据(在houduan/login_bs.js写入)(1)前端代码(在login2.vue文件写入)先启动后端:在terminal终端输入以下命令。

weixin_46452278的博客 210

网络安全面试题

在当今社会网络安全行业越来越发达,也有越来越多的人去学习,为了更好地进行工作,除了学好知识外还要应对企业的面试。 所以在这里我归总了一些网络安全方面的常见面试题,希望对大家有所帮助。 windows常见 1:描述tcp/udp的区别及优劣,及其发展前景 TCP—传输控制协议,提供的是面向连接、可靠的字节流服务。当客户和服务器彼此交换数据前,必须先在双方之间建立一个TCP连接,之后才能...

fzx_hsaj的博客 6万+

别再手搓SVG了!用Vue3+SVG.js快速搭建电力系统拓扑图(附完整代码)

本文介绍了如何利用Vue3和SVG.js实现电力系统拓扑图的低代码开发,通过组件化和响应式数据绑定,大幅提升开发效率。文章详细解析了SVG.js的核心功能,包括图形创建链式API、动态属性绑定和高级图形操作,并提供了断路器组件的完整实现代码。此外,还分享了性能优化策略和调试技巧,帮助开发者应对大规模拓扑图的挑战。

weixin_27717759的博客 354

Razor 视图 + JavaScript + Vue/iView 组件实现的表格自定义列

本文介绍了如何在 Razor 视图项目中,通过 JavaScript 初始化 Vue 实例,并利用 iView Table 实现表格自定义列。该方案的要点是:服务端输出列定义和行数据,前端将列配置转换为响应式数据,用户通过勾选或拖拽控制列的显隐与顺序。通过本地存储保存用户偏好后,表格自定义列还能在多次访问中保持一致,提升系统的易用性。

qq_59041750的博客 306

Clarity 3D Layout

Clarity 3D Layout Clarity 3D layout是3D 全波电磁场模型提取工具,它可以导入已经设计好的PCB文件也可以直接在Clarity 3D layout创建版图进行3D 全波电磁场模型提取。

m0_53280471的博客 5484

Vue3——第 14 篇:生命周期钩子——从创建到销毁的全过程

Vue 3 生命周期钩子是组件在创建、挂载、更新、卸载等阶段自动执行的函数,提供精准控制时机。核心钩子包括 onMounted(DOM 操作与数据请求)、onUpdated(更新后处理)、onBeforeUnmount(资源清理)等。合理使用可优化性能、管理副作用,提升应用稳定性。

810cheng 280

廖雪峰Python教程[完整版]

廖雪峰的python教程整理的,已经排好版了,而且还是完整的,适合小白

大中型超市收银系统(可直接运行)

光步前台收银系统系统功能:商品销售、商品退货、挂单、收银员权限、会员销售、单品取消、整单取消、商品查询、脱机销售、脱机数据上传,基本资料下载,开钱箱、小票打印、收款结算,支持多币种付款方式(现金、储值卡、礼券、微信等),支持多种促销。运行环境:.net 4.0登陆信息用户名:1001 密码:123456内置商品信息,商品编码可输入00001000 - 00010000,测试效果相关功能键F5 结算 F9挂单 F11商品查询 F8退货 Insert单品取消 Del整单取消 F12开钱箱F2 会员 Ctrl+Z退出系统 Home 锁屏 相关源码待公开

Vue 编译器换掉 source map,快了三四成

Vue 核心仓库中,johnsoncodehk 提出一项关键性能优化 PR(#16126),重构编译器 source map 生成机制,实现 CPU 降低 30%、内存减少 40%,修复十余个报错指错行问题。新方案摒弃旧有双轨映射,改用片段自带来源范围,提升准确性与效率。虽引入破坏性改动(移除 MagicString 导出),影响部分工具链,但已通过生态测试验证。该变更将进入小版本,对构建工具开发者尤为值得关注。PR 地址

前端之虎陈随易 204

IDEA的使用指南

IDEA基于java的使用手册,可以最快速的上手,在使用中不断的丰富自己的使用技巧.

全栈 AI 问答应用开发:FastAPI 异步推理与 Vue 流式渲染实战

原生 EventSource 无法传递复杂请求体,后端同步推理易阻塞事件循环,前端流式解析常遇半包与乱码。FastAPI 的 async def 路由中若直接执行同步推理,事件循环会被瞬间独占,导致并发请求全面排队。思路:原生 EventSource 只能发 GET 请求,无法在请求体中传递复杂的 JSON 上下文,也不支持自定义鉴权头,无法满足多轮对话需求。前端粘包与乱码:TextDecoder 必须带 { stream: true },且必须使用 buffer 变量按 \n\n 拼接未闭合数据。

2402_85729970的博客 287

华为的C语言编程规范.pdf

华为技术有限公司内部技术规范。本规范制定了编写C语言程序的基本原则、规则和建议。从代码的清晰、简洁、可测试、安全、程序效率、可移植各个方面对C语言编程作出了具体指导。

Django 权限系统项目实战详细图文教程 6--系统主页功能实现 2

6、Django 权限系统项目实战详细图文教程 6--系统主页功能实现 2

测试开发 204

用 Go + Edge 把 `dist` 变成双击即用的 exe,Vue 桌面应用打包器

用 Go + Edge 把 `dist` 变成双击即用的 exe

weixin_55636632的博客 389

Vue 前端开发 AI 对话页面:流式消息渲染、文件上传

关键点是不能手动设置 Content-Type 为 multipart/form-data,必须由浏览器自动生成带有 boundary 的请求头,否则后端无法解析。思路:将对象序列化为 JSON 字符串,作为 FormData 的一个普通字段(如 formData.append(‘payload’, JSON.stringify(data))),后端接收该字段后再反序列化。若上传大文件(>10MB),需实现独立的分片上传接口,在提交对话前先获取文件 id 或 url,再随文本发送引用。移除输入框的禁用态;

2402_85729970的博客 390

Vue Router

介绍vue-router的基本使用以及核心概念

k_5412528的博客 200

Avue CRUD自定义操作下拉按钮的最简单方法

Avue CRUD自定义操作下拉按钮的最简单方法。

经验之谈,不做搬运工 54

成为全栈·Vue 管理后台篇·Vue响应式与 React Hooks:同一交互的两种运行模型

本文以同一套管理后台中的文章筛选、分页和权限展示为例,对比 Vue 响应式依赖追踪与 React 组件重新渲染模型。通过 ref、computed、watch 和 useState、useMemo、useEffect,解释两种框架的状态传播、闭包与依赖处理差异,并给出从 React 页面迁移 Vue 时区分本地状态、派生状态、服务器状态和副作用的检查方法。

FungLeo's Blog 248

【深度学习】基于yolo算法的疲劳驾驶检测系统(djang+vue+yolo26m算法)

本文设计并实现了一套基于深度学习的疲劳驾驶检测系统,采用YOLO26m模型与Django+Vue+MySQL架构,支持图片、视频抽帧及摄像头实时检测,具备多源数据融合、可视化统计大屏与用户管理功能。系统通过迁移学习提升识别精度,结合定间隔抽帧策略平衡实时性与算力开销,实现对闭眼、打哈欠等疲劳行为的高效检测。成果已形成完整源码与演示视频,适用于驾培监管、车载预警等场景,具有良好的工程化落地价值。文末可获取源码。

qq_1445749146的博客 235

AI 算法平台项目开发文档:FastAPI+Vue 全栈实现

本文设计并实现一套轻量化AI算法中台平台,基于FastAPI与Vue3构建前后端分离架构,解决模型分散部署、调用协议不统一、缺乏生命周期管理等问题。平台支持同步/异步推理,通过JSON Schema统一接口规范,实现算法配置化接入;采用RBAC权限控制与JWT/APIKey双重认证,保障安全;结合Celery+Redis实现任务异步调度与可视化监控,具备任务追踪、结果导出、调用量统计等能力。系统具备高性能、高可用、可扩展性,适用于毕业设计、企业中台原型及全栈实战项目。

2301_79189962的博客 315

vue2干货总结7(自定义指令、插槽、单页应用程序、路由的使用(VueRouter))

本文介绍了Vue中自定义指令与插槽的使用,涵盖指令的全局/局部注册、动态绑定值及v-loading加载状态封装;详解插槽的默认、具名与作用域插槽应用,实现组件内容定制化;同时对比单页(SPA)与多页应用的优劣,并系统讲解Vue Router的安装、配置与路由映射机制,助力构建高效、可维护的前端应用。

2501_91966873的博客 236

Vue系列——第 15 篇:内置组件全家桶——Transition、Teleport、Suspense、KeepAlive

本文系统讲解 Vue 四大核心内置组件:Transition(过渡动画)、Teleport(DOM 传送)、Suspense(异步依赖处理)和 KeepAlive(组件缓存)。重点介绍 Transition 的 CSS/JS 钩子、类名机制、常见动画效果及与 Animate.css、GSAP 集成;TransitionGroup 实现列表元素的动态过渡;结合实战案例,帮助开发者实现流畅的视觉交互与性能优化。

810cheng 458
上一篇: 博客项目 laravel vue mysql 第五章 标签功能
下一篇: 博客项目 laravel vue mysql 第七章 扩展功能
四季豆豆豆
博客等级 码龄6年 101粉丝 24原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值