基于 Laravel + Vue 框架实现文件异步上传组件和文章封面图片功能


在今天这篇教程中,学院君将给大家演示如何基于 Laravel + Vue 框架编写一个基本的图片异步上传组件,并且带预览功能,然后把这个图片上传组件嵌入到文章发布/编辑表单中,用于给文章设置封面图片,这样一来,文章列表视图就不再那么单调了。

后端接口支持

开始之前,我们先要在 Laravel 后端准备好相应的数据库字段、图片上传接口以及文章封面图片设置功能。

新增文章封面图片字段

component-practice 项目根目录下,运行如下 Artisan 命令为 posts 数据表生成一个新的数据库迁移文件:

php artisan make:migration alter_posts_table_add_image_path --table=posts

-w1052

打开这个刚刚创建的迁移文件,在 posts 表的 title 字段后面新增一个 image_path 字段作为文章封面图片:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AlterPostsTableAddImagePath extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->string('image_path')->nullable()->after('title');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('posts', function (Blueprint $table) {
            $table->dropColumn('image_path');
        });
    }
}

接着运行 php artisan migrate 让这个新增字段生效:

-w832

-w1111

编写图片上传接口

接下来,我们新建一个 ImageController 控制器:

php artisan make:controller ImageController

-w790

在这个新生成的控制器中编写图片上传处理代码如下:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class ImageController extends Controller
{
    public function upload(Request $request)
    {
        $request->validate([
            'image' => 'required|image|max:1024',  // 图片尺寸不能超过 1 MB
        ]);

        if ($request->hasFile('image')) {
            $image = $request->file('image');
            $local_path = $image->storePublicly('images', ['disk' => 'public']);
            $image_path = '/storage/' . $local_path;
            return ['success' => true, 'path' => $image_path];
        }

        return ['success' => false, 'message' => '图片上传失败'];
    }
}

非常简单,如果你对此不熟悉的话可以结合 Laravel 官方文档验证请求文件存储部分进行进一步的了解。

由于我们使用了 public 这个对外开放的磁盘路径,所以需要在运行如下 Artisan 命令在项目根目录下的 public 目录中创建一个 storage 软链接指向 storage 目录下的 public 目录:

php artisan storage:link

最后,控制器方法需要通过路由对外提供服务,因此需要在 routes/web.php 中注册对应的 image/upload 路由:

Route::post('image/upload', [\App\Http\Controllers\ImageController::class, 'upload']);

这样一来,用户就可以通过 image/upload 路由进行文件上传操作了。

编写测试用例测试图片上传

我们可以基于 Laravel 内置的 HTTP 测试功能编写一个测试用例,来测试文件上传接口是否可以正常工作。

使用如下 Artisan 命令创建一个 ImageUploadTest 测试类:

php artisan make:test ImageUploadTest

-w731

然后打开这个文件编写文件上传测试用例:

<?php
namespace Tests\Feature;

use Illuminate\Http\UploadedFile;
use Tests\TestCase;

class ImageUploadTest extends TestCase
{
    /**
     * A basic feature test example.
     *
     * @return void
     */
    public function testImageUpload()
    {
        $response = $this->json('POST', '/image/upload', [
            'image' => UploadedFile::fake()->image('test.jpg')
        ]);

        // 断言响应是否正常
        $response->assertStatus(200);
        $response->assertJsonPath('success', true);
    }
}

这里使用了 UploadedFile::fake() 方法伪造一个 JPG 文件通过 POST 请求上传到 /image/upload 路由,这样就完成了模拟用户在前端上传文件的操作,非常方便。接下来就可以通过对返回的响应实例编写断言看返回结果是否符合预期了,如果符合则测试通过,否则不通过(更多断言方法参考 Laravel 官方文档)。

我们可以通过 php artisan test 命令对这个测试用例进行测试(PhpStorm 中亦可通过图形化界面运行指定测试用例):

-w791

绿色就表示通过,红色则不通过。

文章封面图片设置

接下来,我们来完善文章封面图片设置功能。

Post 模型类中,在批量赋值字段白名单中新增一个 image_path 字段:

class Post extends Model
{
    use HasFactory;

    protected $fillable = ['title', 'content', 'image_path'];

    ...
}

然后打开 PostController 控制器,在发布文章和更新文章后端处理方法中添加针对 image_path 的验证规则即可:

public function store(Request $request)
{
    $data = $request->validate([
        'title' => 'required|max:128',
        'image_path' => 'required',
        'content' => 'required'
    ]);
    
    ...
    
    if ($post->save()) {
        return ['success' => true, 'message' => '文章发布成功', 'id' => $post->id];
    }
    ...
}

...

public function update(Request $request, Post $post)
{
    $data = $request->validate([
        'title' => 'required|max:128',
        'image_path' => 'required',
        'content' => 'required'
    ]);

    ...
    
    if ($post->save()) {
        return ['success' => true, 'message' => '文章更新成功', 'id' => $post->id];
    }
    ...
}

注意到我们在文章保存成功后返回的响应字段中新增了一个 id 字段,用于客户端处理表单提交成功后的页面重定向。

至此,所有的后端代码就编写好了,接下来,我们进入前端实现图片上传和封面图片字段设置。

在前端表单组件中上传封面图片

编写 InputFile 组件

首先,我们在 resources/js/components/form 目录下新建一个 InputFile.vue 文件作为图片上传组件,并初始化组件代码如下:

<template>
    <div class="form-group">
        <input class="form-control-file" ref="file" type="file" :name="name" @change="fileUpload">
        <template v-if="file_path">
            <InputText type="hidden" :name="field" v-model="file_path"></InputText>
            <img :src="file_path" class="img-thumbnail" style="width: 50%;" alt="封面图片预览" v-if="name === 'image'">
        </template>
    </div>
</template>

<script>
import InputText from "./InputText";
export default {
    components: {InputText},
    props: ['name', 'field', 'path'],
    data() {
        return {
            file_path: this.path
        }
    },
    methods: {
        fileUpload() {
            this.$emit('clear');
            let form_data = new FormData();
            form_data.append(this.name, this.$refs.file.files[0]);
            axios.post('/' + this.name + '/upload', form_data, {
                headers: {
                    'Content-Type': 'multipart/form-data'
                }
            }).then(resp => {
                this.file_path = resp.data.path;
                this.$emit('success', this.field, this.file_path);
            }).catch(error => {
                let errors = {};
                let error_bag = error.response.data.errors;
                errors[this.field] = error_bag[this.name];
                this.$emit('error', errors);
            });
        }
    }
}
</script>

在这段模板代码中,我们引入了一个 Bootstrap 的文件上传表单元素,如果从父级作用域传递过来的文件路径不为空,或者在当前组件中上传文件成功的话,还会渲染一个包含对应文件路径值的隐藏字段,如果文件类型是图片的话,还支持对该图片进行预览。

显然我们这个文件上传组件是通用的,不仅仅支持图片上传,还支持其他类型文件上传。

相关的 props 属性和数据模型属性绑定就不多做介绍了,我们看下这里监听的文件事件:当我们在文件表单元素上选择一个本地文件后,会触发该元素的 change 事件,然后执行与该事件绑定的 fileUpload 方法。

在这个方法中,我们首先会触发父级作用域上的 clear 事件清空报错消息(马上会展示父级作用域对应的设置),然后基于 axios 库 API 发起一个文件上传异步请求,对应的后端接口正是上面 Laravel 后端新增的 image/upload 接口。注意到这里设置了额外的请求头,因为文件上传要求内容类型必须是 multipart/form-data

文件上传成功后,会覆盖 file_path 属性值填充隐藏的文件路径字段,如果是图片类型的话会渲染预览图片,并且还会触发父级作用域的 success 事件以便执行对应的业务逻辑,比如填充文章封面图片路径字段值。

文件上传失败后,则会触发父级作用域的 error 事件以便执行相应的错误显示逻辑。

在表单组件中引入 InputFile

接下来,我们打开文章发布/编辑表单组件 PostForm,在其中引入文件上传组件实现封面图片的上传和设置:

<template>
    <FormSection @store="store">
        <template slot="title">{{ title }}</template>
        <template slot="input-group">
            <div class="form-group">
                <Label name="title" label="标题"></Label>
                ...
            </div>
            <div class="form-group">
                <Label name="title" label="封面图片"></Label>
                <InputFile name="image" field="image_path" :path="form.image_path"
                           @clear="clear('image_path')" @success="uploadSuccess" @error="uploadError">
                </InputFile>
                <ErrorMsg :error="form.errors.get('image_path')"></ErrorMsg>
            </div>
            ...
        </template>
        ...
    </FormSection>
</template>

<script>
import FormSection from './form/FormSection';
import InputText from './form/InputText';
import InputFile from "./form/InputFile";
import TextArea from './form/TextArea';
import Button from './form/Button';
import ToastMsg from './form/ToastMsg';
import Label from "./form/Label";
import ErrorMsg from "./form/ErrorMsg";

export default {

    components: {FormSection, InputText, InputFile, TextArea, Label, ErrorMsg, Button, ToastMsg},

    props: ['title', 'url', 'action', 'post'],

    data() {
        let post_data = this.post ? JSON.parse(this.post) : null;
        return {
            form: new Form({
                title: this.post ? post_data.title : '',
                content: this.post ? post_data.content : '',
                image_path: this.post ? post_data.image_path : ''
            })
        }
    },

    methods: {
        store() {
            let method = this.action === 'create' ? 'post' : 'put';
            this.form[method](this.url)
                .then(data => {
                    // 发布/更新成功后都跳转到详情页
                    window.location.href = '/posts/' + data.id;
                })
                .catch(data => console.log(data)); // 自定义表单提交失败处理逻辑
        },
        clear(field) {
            this.form.errors.clear(field);
        },
        uploadSuccess(field, path) {
            this.form[field] = path;
        },
        uploadError(errors) {
            this.form.errors.set(errors);
        }
    }
}
</script>

我们在标题之后引入了文件上传组件,并设置了一应需要传递到子组件的 props 属性,以及三个事件函数,分别处理子组件上报的 clearsuccesserror 事件,我们通过这种机制实现子组件和父级作用域的通信。

clear 事件函数前面已经介绍过了,就是清理封面图片字段相关的错误信息;success 事件函数用于图片上传成功后设置 form 实例的 image_path 属性,以便后续提交表单时使用;error 事件函数用于设置 image_path 字段的错误消息。

另外,我们还改造了编辑表单数据初始化逻辑,因为原来的异步加载存在延时,相应的需要在 views/posts/edit.blade.php 视图中引入 PostForm 组件的地方传递 JSON 格式的 $post 实例数据过来:

<post-form title="编辑文章" action="update" post="{{ json_encode($post) }}" url="{{ route('posts.update', ['post' => $post->id]) }}">
</post-form>

当然,PostController 控制器中 edit 方法传递进视图的数据也要调整:

public function edit(Post $post)
{
    return view('posts.edit', ['pageTitle' => '编辑文章', 'post' => $post]);
}

此外,在 PostForm 中,我们也将文章发布和文章更新成功后的处理逻辑也统一成跳转到文章详情页。

以上就是 PostForm 表单针对封面图片上传和设置功能的所有代码调整了,此时打开文章发布页面,就可以看到包含封面图片字段的表单了:

-w781

上传文章封面图片

我们可以在这个发布文章表单中测试封面图片上传功能,上传成功后可以立即看到预览图片的渲染,表明图片上传成功:

-w780

你还可以在 Vue Devtools 面板中看到 form.image_path 属性中也成功设置了图片路径:

-w1370

点击「立即发布」按钮发布这篇文章,就可以跳转到文章详情页了,点击编辑链接,进入该文章的编辑页面,由于初始 image_path 值不为空,所以可以看到对应的封面图片预览:

-w776

你可以在文章编辑页面上传其他图片覆盖老的封面图片:

-w816

发布成功,则表明文章发布和编辑表单中的封面图片上传和设置功能可以正常工作。至此,我们就完成了文章封面图片上传和设置的全部工作。

在文章列表页展示封面图片

最后,我们打开 list/CardItem 组件,在文章列表页卡片视图中渲染文章封面图片,让列表页看起来更好看一些,这里我们把 Bootstrap 带封面图片的 Card 组件 HTML 模板拷贝过来,加上 Vue 条件渲染指令进行展示即可(如果有封面图片的话显示封面图片,否则显示灰色背景):

<template>
    <div class="col mb-4">
        <div class="card">
            <img :src="post.image_path" class="card-img-top" height="180" :alt="'点击阅读' + post.title" v-if="post.image_path">
            <svg class="bd-placeholder-img card-img-top" width="100%" height="180" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid slice" focusable="false" role="img" v-else>
                <rect width="100%" height="100%" fill="#868e96"></rect>
            </svg>
            <div class="card-body">
                <h5 class="card-title"><slot name="title"></slot></h5>
                <p class="card-text">
                    <small class="text-muted">
                        <svg width="1em" height="1em" viewBox="0 0 16 16" class="bi bi-person-circle" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
                            <path d="M13.468 12.37C12.758 11.226 11.195 10 8 10s-4.757 1.225-5.468 2.37A6.987 6.987 0 0 0 8 15a6.987 6.987 0 0 0 5.468-2.63z"/>
                            <path fill-rule="evenodd" d="M8 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/>
                            <path fill-rule="evenodd" d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zM0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8z"/>
                        </svg>
                        {{ post.author.name}}
                    </small>
                    ...
                </p>
                ...
            </div>
        </div>
    </div>
</template>

另外,我们还将卡片中的状态字段调整为了作者字段,相应的,需要在后端接口数据中添加这段渴求式加载逻辑:

public function all()
{
    return Post::with('author')->orderByDesc('created_at')->get();
}

为了优化用户体验,我们还在异步加载文章列表数据期间为列表页加上了用户友好的加载提示效果(和文章详情页一样),在 list/ListSection.vue 组件中通过插槽定义加载提示位置:

<div class="card-body">
    <slot name="loading"></slot>  // 在这里插入加载动态图
    <ul class="list-group" v-if="view.mode === 'list'">
        <slot></slot>
    </ul>
    <div class="row row-cols-1 row-cols-md-3" v-else>
        <slot></slot>
    </div>
</div>

然后在父级作用域 PostList 中编写对应的动态加载效果,以及将默认视图模式切换成卡片视图:

<template>
    <div class="post-list">
        <ListSection :view_mode="view_mode" @view-mode-changed="change_view_mode">
            ...
            <template #loading>
                <div class="spinner-border" role="status" v-if="!loaded">
                    <span class="sr-only">Loading...</span>
                </div>
            </template>
        </ListSection>
    </div>
</template>

<script>
...

export default {
    ...
    data() {
        return {
            posts: [],
            view_mode: 'card',
            loaded: false
        }
    },
    ...
}
</script>

现在,我们访问文章列表页,数据加载完成之前会有一个动态加载中的提示:

-w1136

加载完成后,则会默认以卡片模式展示包含封面图片的文章列表数据,是不是比原来好看多了:

-w1142

关于文件异步上传和封面图片设置我们就简单介绍到这里,下篇教程,学院君将给大家演示如何在 Vue 框架中实现拖放式图片上传组件。


Vote Vote Cancel Collect Collect Cancel

<< 上一篇: 给 Vue 模态框组件的打开关闭添加过渡/动画效果

>> 下一篇: 在 Vue 中实现拖放式图片上传组件